Compare commits

..

37 Commits

Author SHA1 Message Date
e9cc81b582 live scan fix 2 2026-05-03 22:42:14 +03:00
a153b397d3 live scan fix 2026-05-03 22:25:30 +03:00
51485c0048 fix planner 2026-05-03 14:33:09 +03:00
cf23f38a6e fix world 2026-05-03 14:19:34 +03:00
11f30f4c6a various fixes totest 2026-05-03 13:50:37 +03:00
eb31072c87 MJ: attack coordinator update / various fixes . captcha and back button and jitterloop 2 secs 2026-05-03 12:40:20 +03:00
47381a9304 live tracker 2026-05-03 03:04:47 +03:00
76b991a62b blue fix 2026-05-02 11:42:05 +03:00
f5231a2524 try to fix 2026-05-02 02:58:41 +03:00
84de7082ec blue fix 2026-05-02 02:45:42 +03:00
83b8c85557 timestamp 2026-05-02 02:36:48 +03:00
d8ba139d07 2 bugs / farm and bandit 2026-05-02 02:21:32 +03:00
6157ae1034 fix ? 2026-05-02 01:40:21 +03:00
4272edf432 farm fix between worlds 2026-05-02 01:28:20 +03:00
502b330ac5 ?? 2026-05-02 01:23:30 +03:00
1c65043eb3 final fix? 2026-05-02 01:20:25 +03:00
5c4d415fdd debug 2026-05-02 01:14:12 +03:00
f22b92ae89 debug 2026-05-02 01:11:42 +03:00
0f54ef9191 ficx 4 2026-05-02 01:07:01 +03:00
45e71ed90b debug 3 2026-05-02 01:03:44 +03:00
66dcb71a8d debug 2 2026-05-02 01:00:23 +03:00
b36b11393f debug 1 2026-05-02 00:57:23 +03:00
90ce6a029d fix 3 2026-05-02 00:53:47 +03:00
c9e6522f12 fix 2 2026-05-02 00:48:37 +03:00
1cb5dca3c2 fix 1 2026-05-02 00:36:38 +03:00
2552d3d075 fix different world different admin 2026-05-02 00:25:02 +03:00
5f6855ec69 blueprint function 2026-05-02 00:08:43 +03:00
05785c294e fix 2 2026-05-01 22:42:16 +03:00
614029e527 ui revamp 2026-05-01 22:32:52 +03:00
a572feef14 fix executing 2026-05-01 21:42:32 +03:00
b18e2e8f97 fix 2 2026-05-01 21:25:40 +03:00
2769091b74 fix 1 2026-05-01 21:16:20 +03:00
f4a0e18686 redesign of recruit troops 2026-05-01 21:11:47 +03:00
d6c2252f5c stash reward fix 2 2026-05-01 17:50:39 +03:00
bcda80e127 stashreward / use reward fix 2026-05-01 16:56:23 +03:00
731a7b2f3b ui tweak 2026-05-01 16:34:46 +03:00
f82893164e stucked commands 2026-05-01 16:02:49 +03:00
29 changed files with 3623 additions and 396 deletions

View File

@@ -822,11 +822,12 @@
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}`);

10
app.py
View File

@@ -5,6 +5,14 @@ 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
from routes.attack_planner import attack_planner
import logging
logging.basicConfig(
level=logging.WARNING,
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s'
)
# Initialise DB schema when the app starts
init_db()
@@ -63,6 +71,8 @@ def handle_options(path):
app.register_blueprint(api)
app.register_blueprint(dashboard)
app.register_blueprint(auth)
app.register_blueprint(tracker)
app.register_blueprint(attack_planner)
if __name__ == '__main__':
print("✅ Grepolis Remote — DB initialised")

221
blueprint_engine.py Normal file
View 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}")

View File

@@ -14,15 +14,16 @@ function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Schedules fn to run after a random ms delay, then reschedules itself
function jitterLoop(fn, minMs, maxMs) {
function schedule() {
// 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));
schedule(randInt(minMs, maxMs));
}, delay);
}
schedule();
schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs));
}
function log(msg) {

View File

@@ -193,7 +193,27 @@ function gatherState() {
};
});
return { player, player_id, alliance_id, total_points, world_id: world, towns: townList };
// ---- World speed & unit speed table (for attack planner calculations) -----
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}`); }
return { player, player_id, alliance_id, total_points, world_id: world,
world_speed, unit_speeds, towns: townList };
}
function pushState() {

View File

@@ -7,8 +7,9 @@ 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}`, {
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 })

View File

@@ -17,13 +17,13 @@ let lastKnownBotSettings = {};
// ----------------------------------------------------------------
// botLog — sends a log entry to the server
// ----------------------------------------------------------------
async function botLog(player_id, feature, message) {
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, feature, message })
body: JSON.stringify({ player_id, world_id, feature, message })
});
} catch (e) { /* non-critical */ }
}
@@ -38,7 +38,8 @@ async function autoBootcampLoop() {
if (!settings.bootcamp_enabled) return;
const player_id = uw.Game?.player_id;
if (!player_id) return;
const world_id = uw.Game?.world_id;
if (!player_id || !world_id) return;
let model;
try {
@@ -61,27 +62,30 @@ async function autoBootcampLoop() {
const isFavor = reward.power_id?.includes('favor');
const stashable = reward.stashable;
if (isInstant && !isFavor) {
const useReward = () => {
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
model_url: `PlayerAttackSpot/${player_id}`,
action_name: 'useReward',
arguments: {}
});
await botLog(player_id, 'bootcamp', `Reward used: ${reward.power_id}`);
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
});
await botLog(player_id, 'bootcamp', `Reward stashed: ${reward.power_id}`);
} else {
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
model_url: `PlayerAttackSpot/${player_id}`,
action_name: 'useReward',
arguments: {}
});
await botLog(player_id, 'bootcamp', `Reward used (fallback): ${reward.power_id}`);
useReward();
}
await sleep(randInt(3000, 7000));
return; // Wait for next cycle to attack
@@ -99,7 +103,7 @@ async function autoBootcampLoop() {
const cooldown = model.getCooldownDuration();
if (cooldown > 0) {
const minRemaining = Math.round(cooldown / 60);
await botLog(player_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
await botLog(player_id, world_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
return;
}
@@ -108,7 +112,7 @@ async function autoBootcampLoop() {
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, 'bootcamp', 'Attack already in flight — skipping');
await botLog(player_id, world_id, 'bootcamp', 'Attack already in flight — skipping');
return;
}
}
@@ -141,7 +145,7 @@ async function autoBootcampLoop() {
}
if (Object.keys(units).length === 0) {
await botLog(player_id, 'bootcamp', 'No available units — skipping attack');
await botLog(player_id, world_id, 'bootcamp', 'No available units — skipping attack. (Χωρίς αμυντικά)');
return;
}
@@ -152,10 +156,10 @@ async function autoBootcampLoop() {
});
const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', ');
await botLog(player_id, 'bootcamp', `Attack sent — ${unitSummary}`);
await botLog(player_id, world_id, 'bootcamp', `Στέλνω ${JSON.stringify(units)} στο camp...`);
} catch (e) {
await botLog(player_id, 'bootcamp', `Error during attack: ${e}`);
await botLog(player_id, world_id, 'bootcamp', `Error during attack: ${e}`);
}
}
@@ -176,7 +180,8 @@ async function autoRuralTradeLoop() {
if (!settings.rural_trade_enabled) return;
const player_id = uw.Game?.player_id;
if (!player_id) return;
const world_id = uw.Game?.world_id;
if (!player_id || !world_id) return;
const minRatio = RATIO_MAP[settings.rural_trade_ratio] ?? 0.75;
@@ -274,12 +279,12 @@ async function autoRuralTradeLoop() {
arguments: { farm_town_id: farm.attributes.id, amount },
town_id: parseInt(town_id_str)
});
await botLog(player_id, 'rural_trade',
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, 'rural_trade', `Trade error: ${e}`);
await botLog(player_id, world_id, 'rural_trade', `Trade error: ${e}`);
}
await sleep(randInt(800, 1800));
@@ -289,7 +294,7 @@ async function autoRuralTradeLoop() {
}
if (!tradeMade && missingResource) {
await botLog(player_id, 'rural_trade',
await botLog(player_id, world_id, 'rural_trade',
`${town_obj.getName?.() ?? town_id_str} needs ${missingResource} but no suitable village found`);
}

View File

@@ -17,15 +17,18 @@ const LOOT_TIMINGS = { 1: 300000, 2: 1200000, 3: 5400000, 4: 14400000 };
// Delay = loot_option cooldown + random 30-120s human jitter.
// This mirrors ModernBot's pattern: run exactly when farms are ready.
// ----------------------------------------------------------------
function scheduleNextFarm() {
const option = lastKnownFarmSettings.loot_option || 1;
const baseMs = LOOT_TIMINGS[option] || 300000;
const jitterMs = randInt(30000, 120000); // +30 to +120 s
const totalMs = baseMs + jitterMs;
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min (option ${option} + ${(jitterMs/1000).toFixed(0)}s jitter)`);
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();
scheduleNextFarm(false);
}, totalMs);
}
@@ -37,11 +40,12 @@ function scheduleNextFarm() {
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}`);
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}`);
@@ -184,7 +188,8 @@ async function autoFarmLoop() {
if (allFull) {
log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle');
try {
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}`, {
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 })
@@ -199,7 +204,8 @@ async function autoFarmLoop() {
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
// Report success so dashboard shows last_farmed_at
try {
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}`, {
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 })
@@ -222,15 +228,23 @@ function boot() {
detectCaptcha();
setTimeout(pushState, 5000);
jitterLoop(pushState, 60000, 120000); // state sync every 12 min
jitterLoop(pollAndExecute, 8000, 18000); // command poll every 818 s
scheduleNextFarm(); // auto-farm timer-based (loot_option + 30120s)
jitterLoop(pollAndExecute, 8000, 18000, 2000); // command poll every 818 s, but start in 2s
scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30120s)
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 min
jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 2545 min
if (typeof window._grcInitTracker === 'function') {
window._grcInitTracker(); // live tracker event-driven
}
if (typeof window._grcInitAttackPlanner === 'function') {
window._grcInitAttackPlanner(); // attack planner countdown engine
}
}
if (document.readyState === 'complete') {
// Page already loaded (normal case when eval()'d dynamically)
boot();
// 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
View 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;
})();

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

118
db.py
View File

@@ -92,7 +92,86 @@ def init_db():
''')
c.execute('CREATE INDEX IF NOT EXISTS idx_bot_logs_player_feature ON bot_logs(player_id, feature)')
# 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',
@@ -103,6 +182,7 @@ def init_db():
'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)',
]:
try:
@@ -138,19 +218,53 @@ def init_db():
)
''')
# Clan members — links Grepolis player_ids to a clan
# 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)
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('''

111
future_ideas.md Normal file
View File

@@ -0,0 +1,111 @@
# 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 and attack planner 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.
```

View File

@@ -1,9 +1,13 @@
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__)
@@ -25,17 +29,18 @@ def _get_clan_from_request():
# ------------------------------------------------------------------
# Helper — auto-register a player_id under a clan on first push.
# ------------------------------------------------------------------
def _auto_register_member(clan_id, player_id, player_name):
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)
VALUES (?, ?, ?)
''', (clan_id, str(player_id), player_name or ''))
# Update name in case it changed
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 = ?
''', (player_name or '', clan_id, str(player_id)))
WHERE clan_id = ? AND player_id = ? AND world_id = ?
''', (player_name or '', clan_id, str(player_id), world_id))
conn.commit()
conn.close()
@@ -59,7 +64,7 @@ def receive_state():
# 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)
_auto_register_member(clan['id'], player_id, player, world_id)
conn = get_db()
c = conn.cursor()
@@ -94,24 +99,60 @@ def receive_state():
datetime.utcnow().isoformat()
))
conn.commit()
# Store world speed + unit data for attack planner calculations
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)
conn.close()
return jsonify({'ok': True, 'towns_updated': len(towns)})
# ------------------------------------------------------------------
# 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):
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.)."""
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()
# 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('''
@@ -127,27 +168,61 @@ def _fetch_pending_of_type(c, cmd_type, player_id):
}
def _fetch_pending_builds_all_towns(c, player_id):
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).
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()
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'
@@ -172,23 +247,36 @@ def _fetch_pending_builds_all_towns(c, player_id):
@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_cmds = _fetch_pending_builds_all_towns(c, player_id) # one per town
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)
research_cmd = _fetch_pending_of_type(c, 'research', 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))
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)
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,
@@ -197,7 +285,7 @@ def get_pending_command():
# Bot settings (bootcamp + rural trade)
bot_row = c.execute(
'SELECT * FROM bot_settings WHERE player_id = ?', (str(player_id),)
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
).fetchone()
bot_settings = {
'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False,
@@ -207,7 +295,7 @@ def get_pending_command():
}
# One-shot manual attack flag
attack_now_key = f'bootcamp_attack_now_{player_id}'
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
@@ -295,10 +383,13 @@ def command_result(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
''', (f'last_farmed_{cmd["player_id"]}', now, now))
''', (lf_key, now, now))
conn.commit()
conn.close()
@@ -313,12 +404,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('''
@@ -332,6 +425,7 @@ def captcha_alert():
conn.close()
return jsonify({'ok': True})
# ------------------------------------------------------------------
# POST /api/market_data
# Tampermonkey uploads the market scan data.
@@ -363,9 +457,12 @@ def upload_market_data():
@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
kv_key = f'farm_status_{player_id}'
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 {}
@@ -379,7 +476,7 @@ def farm_status():
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_id}', now, now))
''', (f'last_farmed_{player_key}', now, now))
conn.commit()
conn.close()
return jsonify({'ok': True})
@@ -401,16 +498,19 @@ def api_bot_logs():
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_id, feature, message)
(player_key, feature, message)
)
# Keep only latest 50 per player/feature
conn.execute('''
@@ -421,7 +521,7 @@ def api_bot_logs():
WHERE player_id = ? AND feature = ?
ORDER BY id DESC LIMIT 50
)
''', (player_id, feature, player_id, feature))
''', (player_key, feature, player_key, feature))
conn.commit()
conn.close()
return jsonify({'ok': True})

646
routes/attack_planner.py Normal file
View 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)

View File

@@ -126,13 +126,14 @@ def options():
members = []
if clan:
rows = conn.execute(
'''SELECT cm.id, cm.player_id, cm.player_name, cm.joined_at, cm.features,
ts.updated_at
'''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
ORDER BY cm.joined_at DESC''',
GROUP BY cm.player_id, cm.world_id
ORDER BY cm.player_name ASC, cm.world_id ASC''',
(clan['id'],)
).fetchall()
@@ -147,13 +148,16 @@ def options():
except Exception:
pass
members.append({
'id': row['id'],
'player_id': row['player_id'],
'player_name': row['player_name'] 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'),
'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'),
'feat_atk_planner': 'attack_planner' in (row['features'] or ''),
'feat_atk_planner_admin': 'attack_planner_admin' in (row['features'] or ''),
})
conn.close()
@@ -209,17 +213,17 @@ def regenerate_key():
return redirect(url_for('auth.options'))
@auth.route('/auth/clan/remove-member/<player_id>', methods=['POST'])
@auth.route('/auth/clan/remove-member/<player_id>/<path:world_id>', methods=['POST'])
@login_required
def remove_member(player_id):
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 = ?',
(clan['id'], player_id)
'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ? AND world_id = ?',
(clan['id'], player_id, world_id)
)
conn.commit()
conn.close()
@@ -229,12 +233,15 @@ def remove_member(player_id):
# ------------------------------------------------------------------
# POST /auth/clan/update-features/<player_id>
# ------------------------------------------------------------------
@auth.route('/auth/clan/update-features/<player_id>', methods=['POST'])
@auth.route('/auth/clan/update-features/<player_id>/<path:world_id>', methods=['POST'])
@login_required
def update_member_features(player_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 ''
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
atk_planner = 'attack_planner' if request.form.get('attack_planner') else None
atk_planner_admin = 'attack_planner_admin' if request.form.get('attack_planner_admin') else None
features = ','.join(f for f in [farm, admin, atk_planner, atk_planner_admin] if f) or ''
conn = get_db()
clan = conn.execute(
@@ -242,14 +249,15 @@ def update_member_features(player_id):
).fetchone()
if clan:
conn.execute(
'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ?',
(features, clan['id'], player_id)
'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
# ------------------------------------------------------------------

View File

@@ -26,16 +26,26 @@ def index():
# Only fetch players that are members of this clan
rows = conn.execute('''
SELECT ts.player, ts.player_id, MAX(ts.updated_at) as last_seen, MAX(ts.world_id) as world_id
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
ORDER BY ts.player ASC
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 = []
@@ -50,31 +60,59 @@ def index():
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',
'world_id': wid or 'Unknown',
'is_online': is_online,
'captcha_active': active_captchas.get(r['player_id'], False)
'captcha_active': captcha_active
})
return render_template('index.html', players=players, no_clan=False)
@dashboard.route('/player/<player_id>')
@dashboard.route('/player/<player_id>/<world_id>')
@login_required
def player_hub(player_id):
return render_template('hub.html', player_id=player_id)
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>/admin')
@dashboard.route('/player/<player_id>/<world_id>/admin')
@login_required
def player_dashboard(player_id):
return render_template('dashboard.html', player_id=player_id)
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>/farm')
@dashboard.route('/player/<player_id>/<world_id>/farm')
@login_required
def player_farm(player_id):
return render_template('farm.html', player_id=player_id)
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>/attack-planner')
@login_required
def player_attack_planner(player_id, world_id):
clan_key = ''
if current_user.clan_id:
conn = get_db()
clan = conn.execute(
'SELECT clan_key FROM clans WHERE id = ?', (current_user.clan_id,)
).fetchone()
conn.close()
if clan:
clan_key = clan['clan_key']
return render_template('attack_planner.html',
player_id=player_id,
world_id=world_id,
clan_key=clan_key)
# ------------------------------------------------------------------
@@ -84,9 +122,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:
@@ -99,6 +140,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()
@@ -109,7 +153,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})
@@ -122,14 +166,22 @@ 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
# 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 = ?", (f'last_farmed_{player_id}',)
"SELECT value FROM kv_store WHERE key = ?", (lf_key,)
).fetchone()
last_farmed_at = lf_row['value'] if lf_row else None
conn.close()
@@ -176,14 +228,26 @@ def get_market_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 = []
@@ -220,11 +284,95 @@ 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'])
})
return jsonify(towns)
# ------------------------------------------------------------------
# GET /dashboard/clan-towns?world_id=...
# Returns all towns for ALL clan members in a given world.
# Used by attack planner to let admin pick any player's town.
# ------------------------------------------------------------------
@dashboard.route('/dashboard/clan-towns', methods=['GET'])
@login_required
def get_clan_towns():
world_id = request.args.get('world_id', '').strip()
clan_id = current_user.clan_id
if not clan_id or not world_id:
return jsonify([])
conn = get_db()
rows = conn.execute('''
SELECT ts.town_id, ts.town_name, ts.player, ts.player_id,
ts.x, ts.y, ts.sea, ts.world_id, ts.updated_at, ts.data
FROM town_state ts
INNER JOIN clan_members cm
ON cm.player_id = ts.player_id
AND cm.world_id = ts.world_id
AND cm.clan_id = ?
WHERE ts.world_id = ?
ORDER BY ts.player ASC, ts.town_name ASC
''', (clan_id, world_id)).fetchall()
conn.close()
towns = []
for r in rows:
d = json.loads(r['data']) if r['data'] else {}
towns.append({
'town_id': r['town_id'],
'town_name': r['town_name'],
'player': r['player'],
'player_id': r['player_id'],
'world_id': r['world_id'],
'x': r['x'],
'y': r['y'],
'sea': r['sea'],
'units': d.get('units', {}),
})
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.
@@ -259,16 +407,26 @@ 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,
@@ -325,13 +483,24 @@ def reorder_commands():
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])
@@ -450,15 +619,18 @@ def fail_stale_commands():
@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_id,)
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
).fetchone()
conn.close()
if row:
@@ -484,7 +656,7 @@ def bot_settings():
rural_trade_ratio = excluded.rural_trade_ratio,
updated_at = excluded.updated_at
''', (
player_id,
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))),
@@ -503,16 +675,19 @@ def bot_settings():
@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_id]
params = [player_key]
if feature:
query += ' AND feature = ?'
params.append(feature)
@@ -527,7 +702,7 @@ def bot_logs():
message = data.get('message', '')
c.execute(
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
(player_id, feature, message)
(player_key, feature, message)
)
# Prune: keep only the latest 50 per player/feature
c.execute('''
@@ -538,7 +713,7 @@ def bot_logs():
WHERE player_id = ? AND feature = ?
ORDER BY id DESC LIMIT 50
)
''', (player_id, feature, player_id, feature))
''', (player_key, feature, player_key, feature))
conn.commit()
conn.close()
return jsonify({'ok': True})
@@ -552,9 +727,12 @@ def bot_logs():
@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
key = f'bootcamp_attack_now_{player_id}'
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', ?)

214
routes/tracker.py Normal file
View 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!)
}
)

View File

@@ -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 & Academy Picker Modal
Building, Academy, Unit, Market & Blueprint Modals
========================================================================== */
#building-modal-overlay, #academy-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, #academy-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, #academy-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, #academy-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, #academy-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, #academy-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, #academy-modal-close:hover { color: #fff; }
#building-modal-close:hover, #academy-modal-close:hover, #unit-modal-close:hover { color: #fff; }
#building-grid {
display: grid;

View File

@@ -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);
@@ -52,7 +52,7 @@ 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
if (window._logPanelMode === 'log') {
@@ -97,8 +97,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 = {};
@@ -132,7 +132,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;
@@ -182,6 +182,30 @@ window.sendCommand = async function() {
}
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 {
@@ -221,18 +245,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';
}

View File

@@ -2,13 +2,106 @@
// 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';
document.getElementById('research-options').style.display = type === 'research' ? '' : '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
@@ -118,11 +211,8 @@ 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);
};
@@ -233,77 +323,140 @@ window.closeAcademyModal = function(e) {
window.selectResearch = function(key, name) {
window.selectedResearchId = key;
document.getElementById('selected-research-label').textContent = `🧪 ${name}`;
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 uSelect = document.getElementById('unit-select');
const uData = town.unit_data || {};
const grid = document.getElementById('unit-grid');
const uData = town.unit_data || {};
const currentVal = uSelect.value;
uSelect.innerHTML = '<option value="" disabled selected>-- Επιλέξτε Μονάδα --</option>';
// 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']
};
for (const [key, nameGr] of Object.entries(window.UNIT_NAMES_GR)) {
if (key === 'militia') continue;
let html = '';
const data = uData[key];
let text = `${nameGr}`;
for (const [catName, units] of Object.entries(categories)) {
let catHtml = '';
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;
for (const key of units) {
if (key === 'militia') continue;
// 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`;
}
const nameGr = window.UNIT_NAMES_GR[key] || key;
const data = uData[key];
const icon = window.UNIT_ICONS[key] || '💂';
const isSelected = key === window.selectedUnitId;
const costStr = `Ξ:${w} Π:${st} Α:${i} 🧔:${pop} · ⏱ ${tStr}`;
let statusClass, statusLabel, cardClass = '';
let costStr = '';
let clickable = false;
const missingKeys = data.missing_dependencies ? Object.keys(data.missing_dependencies) : [];
const isLocked = missingKeys.length > 0;
const requiredGod = window.UNIT_GODS ? window.UNIT_GODS[key] : null;
const isWrongGod = requiredGod && town.god !== requiredGod;
const isNoGod = key === 'godsent' && !town.god;
const option = document.createElement('option');
option.value = key;
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;
if (isLocked) {
option.textContent = `${text} — 🔒 Κλειδωμένο`;
option.style.color = '#ff4444';
} else if (data.enough_resources === false) {
option.textContent = `${text} — ❌ ${costStr} (Λείπουν Πόροι 1x)`;
option.style.color = '#aa5555';
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;
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 (data.enough_resources === false) {
statusClass = 'no-resources'; statusLabel = '❌ Λείπουν Πόροι';
} else {
statusClass = 'can-build'; statusLabel = '✅ Διαθέσιμο';
clickable = true;
}
} else {
option.textContent = `${text} — ✅ ${costStr}`;
// 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;">
<span class="bld-icon">${icon}</span>
<span class="bld-name" style="margin-top:6px; font-size:0.85rem;">${nameGr}</span>
<span class="bld-status ${statusClass}">${statusLabel}</span>
<span class="bld-cost" style="font-size:0.65rem;">${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');

View File

@@ -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;' : '';
@@ -221,4 +212,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 = '';
}
}
};

View File

@@ -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>',

View 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)}λεπτά
&nbsp;|&nbsp; Πλοία: ${data.transport_count||0}
&nbsp;|&nbsp; Αποστολή: ${ts(data.send_time)}
&nbsp;|&nbsp; Επιστροφή: ${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>

View File

@@ -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,132 +83,31 @@
</div>
<div id="command-form-wrap" style="display:none">
<div class="command-form">
<div class="command-form" style="display: flex; flex-direction: column; gap: 15px; align-items: flex-start;">
<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>
<option value="research">Ακαδημία - Έρευνες</option>
</select>
<!-- 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" style="display:none">
<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>
<!-- Research options -->
<div class="form-group" id="research-options" style="display:none">
<label>Έρευνα</label>
<button class="btn btn-gold" id="open-academy-modal" onclick="window.openAcademyModal()" style="text-align:left; min-width:200px;">
<span id="selected-research-label">-- Επιλέξτε Έρευνα --</span>
</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>
<!-- Market options -->
<!-- Market options -->
<div class="form-group" id="market-options" style="display:none">
<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%;">
</div>
<div style="flex:1;">
<label>Πόρος Προσφοράς</label>
<select id="market-offer-type">
<option value="wood">Ξύλο</option>
<option value="stone">Πέτρα</option>
<option value="iron">Ασήμι</option>
</select>
</div>
</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%;">
</div>
<div style="flex:1;">
<label>Πόρος Ζήτησης</label>
<select id="market-demand-type">
<option value="iron">Ασήμι</option>
<option value="stone">Πέτρα</option>
<option value="wood">Ξύλο</option>
</select>
</div>
</div>
<div style="display:flex; gap:10px;">
<div style="flex:1;">
<label>Χρόνος (Ώρες)</label>
<select id="market-max-time">
<option value="1800">0.5</option>
<option value="3600">1</option>
<option value="7200">2</option>
<option value="14400">4</option>
<option value="28800">8</option>
<option value="43200">12</option>
<option value="86400">24</option>
<option value="172800">48</option>
</select>
</div>
<div style="flex:1;">
<label>Ορατότητα</label>
<select id="market-visibility">
<option value="allies">Συμμαχία Μόνο</option>
<option value="all">Όλοι</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 id="btn-send" class="btn btn-gold" onclick="window.sendCommand()">Send ⚡</button>
</div>
<div id="build-queue-preview"></div>
@@ -233,6 +128,94 @@
</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 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 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>
</select>
</div>
</div>
<div style="display:flex; gap:10px; margin-bottom:10px;">
<div style="flex:1;">
<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 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; margin-bottom:20px;">
<div style="flex:1;">
<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>
<option value="14400">4</option>
<option value="28800">8</option>
<option value="43200">12</option>
<option value="86400">24</option>
<option value="172800">48</option>
</select>
</div>
<div style="flex:1;">
<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>
</select>
</div>
</div>
<button class="btn btn-gold" onclick="window.saveMarketModal()" style="width:100%; padding:10px; font-weight:bold;">✅ Αποθήκευση Προσφοράς</button>
</div>
</div>
</div>
<!-- ====== Building Picker Modal ====== -->
<div id="building-modal-overlay" onclick="window.closeBuildingModal(event)">
<div id="building-modal">
@@ -247,6 +230,17 @@
</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">
@@ -324,6 +318,7 @@
</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>

View File

@@ -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>
@@ -411,6 +411,7 @@
<script>
const PLAYER_ID = '{{ player_id }}';
const WORLD_ID = '{{ world_id }}';
let selectedOption = 1;
// -- Loot option buttons --
@@ -445,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(() => {
@@ -457,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;
@@ -480,7 +481,7 @@
}
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(resp => {
const data = resp.towns || [];
@@ -563,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 }
})
@@ -590,7 +591,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_loot',
payload: { loot_option: selectedOption }
})
@@ -616,7 +617,7 @@
fetch('/dashboard/bootcamp-attack-now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: PLAYER_ID })
body: JSON.stringify({ player_id: PLAYER_ID, world_id: WORLD_ID })
})
.then(r => r.json())
.then(() => {
@@ -632,7 +633,7 @@
async function checkWarehouseStatus() {
try {
const res = await fetch(`/api/farm_status?player_id=${PLAYER_ID}`);
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';
@@ -650,7 +651,7 @@
}
function loadBotSettings() {
fetch(`/dashboard/bot-settings?player_id=${PLAYER_ID}`)
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;
@@ -668,6 +669,7 @@
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,
@@ -707,9 +709,9 @@
}
function loadBotLogs() {
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&feature=bootcamp`)
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}&feature=rural_trade`)
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));
}

View File

@@ -95,9 +95,16 @@
.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); }
/* Attack Planner — red/orange */
.hub-card.attack { border-color: #301a1a; }
.hub-card.attack::before { background: radial-gradient(circle at top left, rgba(224,85,85,0.08), transparent 70%); }
.hub-card.attack:hover { border-color: #e05555; box-shadow: 0 12px 40px rgba(224,85,85,0.15); }
.hub-card.attack .card-title { color: #e05555; }
.card-icon {
font-size: 2.8rem;
@@ -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 }}/attack-planner" class="hub-card attack">
<span class="card-icon">⚔️</span>
<div class="card-title">Attack Planner</div>
<div class="card-desc">Συντονισμένες επιθέσεις σε ακριβή χρόνο. Υπολογισμός χρόνου αποστολής, επιστροφής και πλοίων.</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) {

View File

@@ -123,7 +123,7 @@
{% endif %}
{% for p in players %}
<a href="/player/{{ p.player_id }}" class="player-card">
<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>

View File

@@ -257,6 +257,7 @@
<thead>
<tr>
<th>Παίκτης</th>
<th>Κόσμος</th>
<th>Κατάσταση</th>
<th>Δυνατότητες</th>
<th>Προστέθηκε</th>
@@ -270,6 +271,13 @@
<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>
@@ -279,7 +287,7 @@
</td>
<td>
{% if clan.owner_id == current_user.id %}
<form method="POST" action="/auth/clan/update-features/{{ m.player_id }}" style="display:inline;">
<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
@@ -287,20 +295,29 @@
<label class="toggle-label">
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
</label>
<label class="toggle-label">
<input type="checkbox" name="attack_planner" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner }}> ⚔️ Planner
</label>
<label class="toggle-label">
<input type="checkbox" name="attack_planner_admin" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner_admin }}> 🎯 Planner 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>
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner else '#8b949e' }};">⚔️ Planner</span>
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner_admin else '#8b949e' }};">🎯 Planner 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 }}"
onsubmit="return confirm('Αφαίρεση παίκτη {{ m.player_name }}?');">
<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 %}

455
templates/tracker.html Normal file
View 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>