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() { async function pollAndExecute() {
if (paused) return; if (paused) return;
const player_id = uw.Game?.player_id; const player_id = uw.Game?.player_id;
const world_id = uw.Game?.world_id;
if (!player_id) return; if (!player_id) return;
let cmdData; let cmdData;
try { 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(); cmdData = await res.json();
} catch (e) { } catch (e) {
log(`Poll failed: ${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.api import api
from routes.dashboard import dashboard from routes.dashboard import dashboard
from routes.auth import auth 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 # Initialise DB schema when the app starts
init_db() init_db()
@@ -63,6 +71,8 @@ def handle_options(path):
app.register_blueprint(api) app.register_blueprint(api)
app.register_blueprint(dashboard) app.register_blueprint(dashboard)
app.register_blueprint(auth) app.register_blueprint(auth)
app.register_blueprint(tracker)
app.register_blueprint(attack_planner)
if __name__ == '__main__': if __name__ == '__main__':
print("✅ Grepolis Remote — DB initialised") 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; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
// Schedules fn to run after a random ms delay, then reschedules itself // Schedules fn to run after a random ms delay, then reschedules itself.
function jitterLoop(fn, minMs, maxMs) { // Optional initialDelayMs sets the delay for the very first run only.
function schedule() { function jitterLoop(fn, minMs, maxMs, initialDelayMs) {
function schedule(delay) {
setTimeout(async () => { setTimeout(async () => {
await fn(); await fn();
schedule(); schedule(randInt(minMs, maxMs));
}, randInt(minMs, maxMs)); }, delay);
} }
schedule(); schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs));
} }
function log(msg) { 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() { function pushState() {

View File

@@ -7,8 +7,9 @@ let captchaActive = false;
function reportCaptcha(detected) { function reportCaptcha(detected) {
const player_id = uw.Game?.player_id; const player_id = uw.Game?.player_id;
const world_id = uw.Game?.world_id || '';
if (!player_id) return; 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ detected }) body: JSON.stringify({ detected })

View File

@@ -17,13 +17,13 @@ let lastKnownBotSettings = {};
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// botLog — sends a log entry to the server // 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}`); log(`[${feature}] ${message}`);
try { try {
await apiFetch(`${BASE_URL}/api/bot-logs`, { await apiFetch(`${BASE_URL}/api/bot-logs`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 */ } } catch (e) { /* non-critical */ }
} }
@@ -38,7 +38,8 @@ async function autoBootcampLoop() {
if (!settings.bootcamp_enabled) return; if (!settings.bootcamp_enabled) return;
const player_id = uw.Game?.player_id; 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; let model;
try { try {
@@ -61,27 +62,30 @@ async function autoBootcampLoop() {
const isFavor = reward.power_id?.includes('favor'); const isFavor = reward.power_id?.includes('favor');
const stashable = reward.stashable; const stashable = reward.stashable;
if (isInstant && !isFavor) { const useReward = () => {
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
model_url: `PlayerAttackSpot/${player_id}`, model_url: `PlayerAttackSpot/${player_id}`,
action_name: 'useReward', action_name: 'useReward',
arguments: {} 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) { } else if (stashable) {
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
model_url: `PlayerAttackSpot/${player_id}`, model_url: `PlayerAttackSpot/${player_id}`,
action_name: 'stashReward', action_name: 'stashReward',
arguments: {} 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 { } else {
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { useReward();
model_url: `PlayerAttackSpot/${player_id}`,
action_name: 'useReward',
arguments: {}
});
await botLog(player_id, 'bootcamp', `Reward used (fallback): ${reward.power_id}`);
} }
await sleep(randInt(3000, 7000)); await sleep(randInt(3000, 7000));
return; // Wait for next cycle to attack return; // Wait for next cycle to attack
@@ -99,7 +103,7 @@ async function autoBootcampLoop() {
const cooldown = model.getCooldownDuration(); const cooldown = model.getCooldownDuration();
if (cooldown > 0) { if (cooldown > 0) {
const minRemaining = Math.round(cooldown / 60); 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; return;
} }
@@ -108,7 +112,7 @@ async function autoBootcampLoop() {
if (movements) { if (movements) {
for (const mv of Object.values(movements)) { for (const mv of Object.values(movements)) {
if (mv.attributes.destination_is_attack_spot || mv.attributes.origin_is_attack_spot) { 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; return;
} }
} }
@@ -141,7 +145,7 @@ async function autoBootcampLoop() {
} }
if (Object.keys(units).length === 0) { 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; return;
} }
@@ -152,10 +156,10 @@ async function autoBootcampLoop() {
}); });
const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', '); 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) { } 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; if (!settings.rural_trade_enabled) return;
const player_id = uw.Game?.player_id; 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; 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 }, arguments: { farm_town_id: farm.attributes.id, amount },
town_id: parseInt(town_id_str) 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}`); `Traded ${amount} ${missingResource}${farm.attributes.name} via ${town_obj.getName?.() ?? town_id_str}`);
tradesTotal++; tradesTotal++;
tradeMade = true; tradeMade = true;
} catch (e) { } 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)); await sleep(randInt(800, 1800));
@@ -289,7 +294,7 @@ async function autoRuralTradeLoop() {
} }
if (!tradeMade && missingResource) { 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`); `${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. // Delay = loot_option cooldown + random 30-120s human jitter.
// This mirrors ModernBot's pattern: run exactly when farms are ready. // This mirrors ModernBot's pattern: run exactly when farms are ready.
// ---------------------------------------------------------------- // ----------------------------------------------------------------
function scheduleNextFarm() { function scheduleNextFarm(isFirstRun = false) {
const option = lastKnownFarmSettings.loot_option || 1; let totalMs = 15000; // 15 seconds for the first run to catch already-ready farms
const baseMs = LOOT_TIMINGS[option] || 300000; if (!isFirstRun) {
const jitterMs = randInt(30000, 120000); // +30 to +120 s const option = lastKnownFarmSettings.loot_option || 1;
const totalMs = baseMs + jitterMs; const baseMs = LOOT_TIMINGS[option] || 300000;
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min (option ${option} + ${(jitterMs/1000).toFixed(0)}s jitter)`); const jitterMs = randInt(30000, 120000); // +30 to +120 s
totalMs = baseMs + jitterMs;
}
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min`);
setTimeout(async () => { setTimeout(async () => {
await autoFarmLoop(); await autoFarmLoop();
scheduleNextFarm(); scheduleNextFarm(false);
}, totalMs); }, totalMs);
} }
@@ -37,11 +40,12 @@ function scheduleNextFarm() {
async function pollAndExecute() { async function pollAndExecute() {
if (paused) return; if (paused) return;
const player_id = uw.Game?.player_id; const player_id = uw.Game?.player_id;
const world_id = uw.Game?.world_id;
if (!player_id) return; if (!player_id) return;
let cmdData; let cmdData;
try { 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(); cmdData = await res.json();
} catch (e) { } catch (e) {
log(`Poll failed: ${e}`); log(`Poll failed: ${e}`);
@@ -184,7 +188,8 @@ async function autoFarmLoop() {
if (allFull) { if (allFull) {
log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle'); log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle');
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ warehouse_full: true }) body: JSON.stringify({ warehouse_full: true })
@@ -199,7 +204,8 @@ async function autoFarmLoop() {
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } }); await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
// Report success so dashboard shows last_farmed_at // Report success so dashboard shows last_farmed_at
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ warehouse_full: false }) body: JSON.stringify({ warehouse_full: false })
@@ -222,15 +228,23 @@ function boot() {
detectCaptcha(); detectCaptcha();
setTimeout(pushState, 5000); setTimeout(pushState, 5000);
jitterLoop(pushState, 60000, 120000); // state sync every 12 min jitterLoop(pushState, 60000, 120000); // state sync every 12 min
jitterLoop(pollAndExecute, 8000, 18000); // command poll every 818 s jitterLoop(pollAndExecute, 8000, 18000, 2000); // command poll every 818 s, but start in 2s
scheduleNextFarm(); // auto-farm timer-based (loot_option + 30120s) scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30120s)
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 min jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 min
jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 2545 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') { if (document.readyState === 'complete') {
// Page already loaded (normal case when eval()'d dynamically) // Page already loaded (normal case when eval()'d dynamically).
boot(); // 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 { } else {
// Fallback: wait for load event (shouldn't happen but safe to keep) // Fallback: wait for load event (shouldn't happen but safe to keep)
window.addEventListener('load', boot); 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)') 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 # Migration: add new columns if upgrading an existing database
for _col in [ for _col in [
'ALTER TABLE town_state ADD COLUMN player_id TEXT', 'ALTER TABLE town_state ADD COLUMN player_id TEXT',
'ALTER TABLE town_state ADD COLUMN alliance_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 commands ADD COLUMN position INTEGER',
'ALTER TABLE farm_settings ADD COLUMN bandit_camp_enabled INTEGER NOT NULL DEFAULT 0', '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 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)', 'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)',
]: ]:
try: 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(''' c.execute('''
CREATE TABLE IF NOT EXISTS clan_members ( CREATE TABLE IF NOT EXISTS clan_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
clan_id INTEGER NOT NULL REFERENCES clans(id), clan_id INTEGER NOT NULL REFERENCES clans(id),
player_id TEXT NOT NULL, player_id TEXT NOT NULL,
player_name TEXT, player_name TEXT,
world_id TEXT NOT NULL DEFAULT '',
features TEXT NOT NULL DEFAULT 'farm,admin', features TEXT NOT NULL DEFAULT 'farm,admin',
joined_at TEXT NOT NULL DEFAULT (datetime('now')), 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 # Migration: Auto-assign existing users to their clan_id if they are the owner
try: try:
c.execute(''' 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 flask import Blueprint, request, jsonify
from db import get_db from db import get_db
import json import json
from datetime import datetime from datetime import datetime, timedelta
import os import os
from flask import make_response from flask import make_response
from blueprint_engine import evaluate_blueprints
import logging
_log = logging.getLogger(__name__)
api = Blueprint('api', __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. # 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 = get_db()
conn.execute(''' conn.execute('''
INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name) INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name, world_id)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
''', (clan_id, str(player_id), player_name or '')) ''', (clan_id, str(player_id), player_name or '', world_id))
# Update name in case it changed # Update name on every push (it can change); world_id is part of the key so no overwrite risk
conn.execute(''' conn.execute('''
UPDATE clan_members SET player_name = ? UPDATE clan_members SET player_name = ?
WHERE clan_id = ? AND player_id = ? WHERE clan_id = ? AND player_id = ? AND world_id = ?
''', (player_name or '', clan_id, str(player_id))) ''', (player_name or '', clan_id, str(player_id), world_id))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -59,7 +64,7 @@ def receive_state():
# Auto-register this player to the clan that matches the key (if any) # Auto-register this player to the clan that matches the key (if any)
clan = _get_clan_from_request() clan = _get_clan_from_request()
if clan: if clan:
_auto_register_member(clan['id'], player_id, player) _auto_register_member(clan['id'], player_id, player, world_id)
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
@@ -94,24 +99,60 @@ def receive_state():
datetime.utcnow().isoformat() datetime.utcnow().isoformat()
)) ))
conn.commit() 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() conn.close()
return jsonify({'ok': True, 'towns_updated': len(towns)}) return jsonify({'ok': True, 'towns_updated': len(towns)})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/commands/pending # GET /api/commands/pending
# Tampermonkey polls this to get the next command to execute. # Tampermonkey polls this to get the next command to execute.
# Returns one 'build' AND one 'recruit' command independently, # Returns one 'build' AND one 'recruit' command independently,
# so both queues are served in parallel without blocking each other. # 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.).""" """Fetch a single oldest pending command of a given type (recruit, market, etc.)."""
row = c.execute('''
SELECT * FROM commands # We use LEFT JOIN because global commands (like farm_loot) use a pseudo town_id like "0_gr121"
WHERE status = 'pending' AND type = ? AND player_id = ? # which does not exist in town_state.
ORDER BY updated_at ASC, id ASC global_town_id = f"0_{world_id}" if world_id else "0"
LIMIT 1
''', (cmd_type, player_id)).fetchone() 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: if not row:
return None return None
c.execute(''' 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. Fetch ONE pending 'build' command per distinct town_id.
This allows all towns to build in parallel within a single poll cycle. 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 Within each town the oldest-updated command is picked first, so requeued
commands (updated_at = now) naturally sort behind fresh ones. 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 # Get every town that has at least one pending build, ordered by
# which town has been waiting longest (MIN updated_at across its commands). # which town has been waiting longest (MIN updated_at across its commands).
town_rows = c.execute(''' if world_id:
SELECT town_id town_rows = c.execute('''
FROM commands SELECT c.town_id
WHERE status = 'pending' AND type = 'build' AND player_id = ? FROM commands c
GROUP BY town_id JOIN town_state ts ON c.town_id = ts.town_id
ORDER BY MIN(updated_at) ASC WHERE c.status = 'pending' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
''', (player_id,)).fetchall() 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 = [] results = []
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
for town_row in town_rows: for town_row in town_rows:
town_id = town_row['town_id'] 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(''' row = c.execute('''
SELECT * FROM commands SELECT * FROM commands
WHERE status = 'pending' AND type = 'build' 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']) @api.route('/api/commands/pending', methods=['GET'])
def get_pending_command(): def get_pending_command():
player_id = request.args.get('player_id') 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: if not player_id:
return jsonify({'error': 'no player_id provided'}), 400 return jsonify({'error': 'no player_id provided'}), 400
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
build_cmds = _fetch_pending_builds_all_towns(c, player_id) # one per town # Free up stuck 'executing' commands (e.g. if the game page was refreshed mid-execution)
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id) two_minutes_ago = (datetime.utcnow() - timedelta(minutes=2)).isoformat()
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id) c.execute('''
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id) UPDATE commands
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id) SET status = 'pending', result_msg = 'Requeued (timeout)'
research_cmd = _fetch_pending_of_type(c, 'research', player_id) 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) 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 settings
farm_row = c.execute( 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() ).fetchone()
farm_settings = { farm_settings = {
'enabled': bool(farm_row['enabled']) if farm_row else False, 'enabled': bool(farm_row['enabled']) if farm_row else False,
@@ -197,7 +285,7 @@ def get_pending_command():
# Bot settings (bootcamp + rural trade) # Bot settings (bootcamp + rural trade)
bot_row = c.execute( bot_row = c.execute(
'SELECT * FROM bot_settings WHERE player_id = ?', (str(player_id),) 'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
).fetchone() ).fetchone()
bot_settings = { bot_settings = {
'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False, '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 # 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() flag_row = c.execute('SELECT value FROM kv_store WHERE key = ?', (attack_now_key,)).fetchone()
if flag_row and flag_row['value'] == '1': if flag_row and flag_row['value'] == '1':
bot_settings['attack_now'] = True bot_settings['attack_now'] = True
@@ -295,10 +383,13 @@ def command_result(cmd_id):
# When an explicit farm_loot command succeeds, record the timestamp # When an explicit farm_loot command succeeds, record the timestamp
if cmd and cmd['type'] == 'farm_loot' and status == 'done' and cmd['player_id']: 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(''' conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at 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.commit()
conn.close() conn.close()
@@ -313,12 +404,14 @@ def command_result(cmd_id):
@api.route('/api/captcha/alert', methods=['POST']) @api.route('/api/captcha/alert', methods=['POST'])
def captcha_alert(): def captcha_alert():
player_id = request.args.get('player_id') player_id = request.args.get('player_id')
world_id = request.args.get('world_id', '').strip()
if not player_id: if not player_id:
return jsonify({'error': 'no player_id provided'}), 400 return jsonify({'error': 'no player_id provided'}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
detected = bool(data.get('detected', False)) 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 = get_db()
conn.execute(''' conn.execute('''
@@ -332,6 +425,7 @@ def captcha_alert():
conn.close() conn.close()
return jsonify({'ok': True}) return jsonify({'ok': True})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /api/market_data # POST /api/market_data
# Tampermonkey uploads the market scan data. # Tampermonkey uploads the market scan data.
@@ -363,9 +457,12 @@ def upload_market_data():
@api.route('/api/farm_status', methods=['POST', 'GET']) @api.route('/api/farm_status', methods=['POST', 'GET'])
def farm_status(): def farm_status():
player_id = request.args.get('player_id') player_id = request.args.get('player_id')
world_id = request.args.get('world_id')
if not player_id: if not player_id:
return jsonify({'error': 'no player_id'}), 400 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() conn = get_db()
if request.method == 'POST': if request.method == 'POST':
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -379,7 +476,7 @@ def farm_status():
conn.execute(''' conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at 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.commit()
conn.close() conn.close()
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -401,16 +498,19 @@ def api_bot_logs():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
player_id = str(data.get('player_id', '')) player_id = str(data.get('player_id', ''))
world_id = str(data.get('world_id', ''))
feature = data.get('feature', '') feature = data.get('feature', '')
message = data.get('message', '') message = data.get('message', '')
if not player_id or not feature or not message: if not player_id or not feature or not message:
return jsonify({'error': 'missing fields'}), 400 return jsonify({'error': 'missing fields'}), 400
player_key = f"{player_id}_{world_id}" if world_id else player_id
conn = get_db() conn = get_db()
conn.execute( conn.execute(
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)', 'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
(player_id, feature, message) (player_key, feature, message)
) )
# Keep only latest 50 per player/feature # Keep only latest 50 per player/feature
conn.execute(''' conn.execute('''
@@ -421,7 +521,7 @@ def api_bot_logs():
WHERE player_id = ? AND feature = ? WHERE player_id = ? AND feature = ?
ORDER BY id DESC LIMIT 50 ORDER BY id DESC LIMIT 50
) )
''', (player_id, feature, player_id, feature)) ''', (player_key, feature, player_key, feature))
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({'ok': True}) 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 = [] members = []
if clan: if clan:
rows = conn.execute( rows = conn.execute(
'''SELECT cm.id, cm.player_id, cm.player_name, cm.joined_at, cm.features, '''SELECT cm.id, cm.player_id, cm.player_name, cm.world_id, cm.joined_at, cm.features,
ts.updated_at MAX(ts.updated_at) as updated_at
FROM clan_members cm FROM clan_members cm
LEFT JOIN town_state ts ON ts.player_id = cm.player_id LEFT JOIN town_state ts ON ts.player_id = cm.player_id
AND ts.world_id = cm.world_id
WHERE cm.clan_id = ? WHERE cm.clan_id = ?
GROUP BY cm.player_id GROUP BY cm.player_id, cm.world_id
ORDER BY cm.joined_at DESC''', ORDER BY cm.player_name ASC, cm.world_id ASC''',
(clan['id'],) (clan['id'],)
).fetchall() ).fetchall()
@@ -147,13 +148,16 @@ def options():
except Exception: except Exception:
pass pass
members.append({ members.append({
'id': row['id'], 'id': row['id'],
'player_id': row['player_id'], 'player_id': row['player_id'],
'player_name': row['player_name'] or 'Άγνωστος', 'player_name': row['player_name'] or 'Άγνωστος',
'joined_at': row['joined_at'][:10] if row['joined_at'] else '', 'world_id': row['world_id'] or '',
'is_online': is_online, 'joined_at': row['joined_at'][:10] if row['joined_at'] else '',
'feat_farm': 'farm' in (row['features'] or 'farm,admin'), 'is_online': is_online,
'feat_admin': 'admin' in (row['features'] or 'farm,admin'), '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() conn.close()
@@ -209,17 +213,17 @@ def regenerate_key():
return redirect(url_for('auth.options')) 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 @login_required
def remove_member(player_id): def remove_member(player_id, world_id):
conn = get_db() conn = get_db()
clan = conn.execute( clan = conn.execute(
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,) 'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
).fetchone() ).fetchone()
if clan: if clan:
conn.execute( conn.execute(
'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ?', 'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ? AND world_id = ?',
(clan['id'], player_id) (clan['id'], player_id, world_id)
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -229,12 +233,15 @@ def remove_member(player_id):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /auth/clan/update-features/<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 @login_required
def update_member_features(player_id): def update_member_features(player_id, world_id):
farm = 'farm' if request.form.get('farm') else None farm = 'farm' if request.form.get('farm') else None
admin = 'admin' if request.form.get('admin') else None admin = 'admin' if request.form.get('admin') else None
features = ','.join(f for f in [farm, admin] if f) or '' 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() conn = get_db()
clan = conn.execute( clan = conn.execute(
@@ -242,14 +249,15 @@ def update_member_features(player_id):
).fetchone() ).fetchone()
if clan: if clan:
conn.execute( conn.execute(
'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ?', 'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ? AND world_id = ?',
(features, clan['id'], player_id) (features, clan['id'], player_id, world_id)
) )
conn.commit() conn.commit()
conn.close() conn.close()
return redirect(url_for('auth.options')) return redirect(url_for('auth.options'))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /auth/clan/add-admin # POST /auth/clan/add-admin
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -26,16 +26,26 @@ def index():
# Only fetch players that are members of this clan # Only fetch players that are members of this clan
rows = conn.execute(''' 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 FROM town_state ts
INNER JOIN clan_members cm ON cm.player_id = ts.player_id AND cm.clan_id = ? INNER JOIN clan_members cm ON cm.player_id = ts.player_id AND cm.clan_id = ?
WHERE ts.player IS NOT NULL WHERE ts.player IS NOT NULL
GROUP BY ts.player, ts.player_id GROUP BY ts.player, ts.player_id, ts.world_id
ORDER BY ts.player ASC ORDER BY ts.player ASC, ts.world_id ASC
''', (clan_id,)).fetchall() ''', (clan_id,)).fetchall()
captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").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() conn.close()
players = [] players = []
@@ -50,31 +60,59 @@ def index():
except Exception: except Exception:
pass 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({ players.append({
'player': r['player'], 'player': r['player'],
'player_id': r['player_id'], 'player_id': r['player_id'],
'world_id': r['world_id'] or 'Unknown', 'world_id': wid or 'Unknown',
'is_online': is_online, '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) return render_template('index.html', players=players, no_clan=False)
@dashboard.route('/player/<player_id>') @dashboard.route('/player/<player_id>/<world_id>')
@login_required @login_required
def player_hub(player_id): def player_hub(player_id, world_id):
return render_template('hub.html', player_id=player_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 @login_required
def player_dashboard(player_id): def player_dashboard(player_id, world_id):
return render_template('dashboard.html', player_id=player_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 @login_required
def player_farm(player_id): def player_farm(player_id, world_id):
return render_template('farm.html', player_id=player_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']) @dashboard.route('/dashboard/farm-settings', methods=['GET'])
def get_farm_settings(): def get_farm_settings():
player_id = request.args.get('player_id') 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() conn = get_db()
row = conn.execute( 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() ).fetchone()
conn.close() conn.close()
if row: if row:
@@ -99,6 +140,9 @@ def set_farm_settings():
if not data or 'player_id' not in data: if not data or 'player_id' not in data:
return jsonify({'error': 'missing player_id'}), 400 return jsonify({'error': 'missing player_id'}), 400
player_id = data['player_id'] 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 enabled = 1 if data.get('enabled') else 0
loot_option = int(data.get('loot_option', 1)) loot_option = int(data.get('loot_option', 1))
conn = get_db() conn = get_db()
@@ -109,7 +153,7 @@ def set_farm_settings():
enabled = excluded.enabled, enabled = excluded.enabled,
loot_option = excluded.loot_option, loot_option = excluded.loot_option,
updated_at = excluded.updated_at updated_at = excluded.updated_at
''', (player_id, enabled, loot_option, datetime.utcnow().isoformat())) ''', (player_key, enabled, loot_option, datetime.utcnow().isoformat()))
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -122,14 +166,22 @@ def set_farm_settings():
@dashboard.route('/dashboard/farm-data', methods=['GET']) @dashboard.route('/dashboard/farm-data', methods=['GET'])
def get_farm_data(): def get_farm_data():
player_id = request.args.get('player_id') player_id = request.args.get('player_id')
world_id = request.args.get('world_id')
conn = get_db() conn = get_db()
rows = conn.execute( if world_id:
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ?', (player_id,) rows = conn.execute(
).fetchall() '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( 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() ).fetchone()
last_farmed_at = lf_row['value'] if lf_row else None last_farmed_at = lf_row['value'] if lf_row else None
conn.close() conn.close()
@@ -176,14 +228,26 @@ def get_market_data():
@dashboard.route('/dashboard/towns', methods=['GET']) @dashboard.route('/dashboard/towns', methods=['GET'])
def get_towns(): def get_towns():
player_id = request.args.get('player_id') player_id = request.args.get('player_id')
world_id = request.args.get('world_id')
conn = get_db() conn = get_db()
rows = conn.execute('''
SELECT town_id, town_name, player, player_id, alliance_id, query = '''
world_id, x, y, sea, data, updated_at SELECT ts.town_id, ts.town_name, ts.player, ts.player_id, ts.alliance_id,
FROM town_state ts.world_id, ts.x, ts.y, ts.sea, ts.data, ts.updated_at,
WHERE player_id = ? tb.blueprint_name, tb.is_active as blueprint_active
ORDER BY town_name ASC FROM town_state ts
''', (player_id, )).fetchall() 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() conn.close()
towns = [] towns = []
@@ -220,11 +284,95 @@ def get_towns():
'bonuses': d.get('bonuses', {}), 'bonuses': d.get('bonuses', {}),
'wonder_points': d.get('wonder_points', 0), 'wonder_points': d.get('wonder_points', 0),
'total_points': d.get('total_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) 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 # GET /dashboard/client-status
# Returns whether the Tampermonkey client is considered online. # Returns whether the Tampermonkey client is considered online.
@@ -259,16 +407,26 @@ def client_status():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@dashboard.route('/dashboard/captcha-status', methods=['GET']) @dashboard.route('/dashboard/captcha-status', methods=['GET'])
def captcha_status(): 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() 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( row = conn.execute(
"SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', ) "SELECT value FROM kv_store WHERE key = ?", (key,)
).fetchone() ).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() conn.close()
active = bool(row and row['value'] == '1') active = bool(row and row['value'] == '1')
return jsonify({'captcha_active': active}) return jsonify({'captcha_active': active})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /dashboard/commands/queue # GET /dashboard/commands/queue
# Returns pending+executing BUILD commands for a specific town, # Returns pending+executing BUILD commands for a specific town,
@@ -325,13 +483,24 @@ def reorder_commands():
def get_commands(): def get_commands():
player_id = request.args.get('player_id') player_id = request.args.get('player_id')
conn = get_db() conn = get_db()
rows = conn.execute(''' world_id = request.args.get('world_id')
SELECT id, town_id, town_name, type, payload, status, result_msg, created_at, updated_at conn = get_db()
FROM commands
WHERE player_id = ? query = '''
ORDER BY id DESC SELECT c.id, c.town_id, c.town_name, c.type, c.payload, c.status, c.result_msg, c.created_at, c.updated_at
LIMIT 50 FROM commands c
''', (player_id, )).fetchall() 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() conn.close()
return jsonify([dict(r) for r in rows]) return jsonify([dict(r) for r in rows])
@@ -450,15 +619,18 @@ def fail_stale_commands():
@dashboard.route('/dashboard/bot-settings', methods=['GET', 'POST']) @dashboard.route('/dashboard/bot-settings', methods=['GET', 'POST'])
def bot_settings(): def bot_settings():
player_id = request.args.get('player_id') or (request.json or {}).get('player_id') 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: if not player_id:
return jsonify({'error': 'missing player_id'}), 400 return jsonify({'error': 'missing player_id'}), 400
player_key = f"{player_id}_{world_id}" if world_id else player_id
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
if request.method == 'GET': if request.method == 'GET':
row = c.execute( row = c.execute(
'SELECT * FROM bot_settings WHERE player_id = ?', (player_id,) 'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
).fetchone() ).fetchone()
conn.close() conn.close()
if row: if row:
@@ -484,7 +656,7 @@ def bot_settings():
rural_trade_ratio = excluded.rural_trade_ratio, rural_trade_ratio = excluded.rural_trade_ratio,
updated_at = excluded.updated_at updated_at = excluded.updated_at
''', ( ''', (
player_id, player_key,
int(bool(data.get('bootcamp_enabled', 0))), int(bool(data.get('bootcamp_enabled', 0))),
int(bool(data.get('bootcamp_use_def', 0))), int(bool(data.get('bootcamp_use_def', 0))),
int(bool(data.get('rural_trade_enabled', 0))), int(bool(data.get('rural_trade_enabled', 0))),
@@ -503,16 +675,19 @@ def bot_settings():
@dashboard.route('/dashboard/bot-logs', methods=['GET', 'POST']) @dashboard.route('/dashboard/bot-logs', methods=['GET', 'POST'])
def bot_logs(): def bot_logs():
player_id = request.args.get('player_id') or (request.json or {}).get('player_id') 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: if not player_id:
return jsonify({'error': 'missing player_id'}), 400 return jsonify({'error': 'missing player_id'}), 400
player_key = f"{player_id}_{world_id}" if world_id else player_id
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
if request.method == 'GET': if request.method == 'GET':
feature = request.args.get('feature', '') feature = request.args.get('feature', '')
query = 'SELECT * FROM bot_logs WHERE player_id = ?' query = 'SELECT * FROM bot_logs WHERE player_id = ?'
params = [player_id] params = [player_key]
if feature: if feature:
query += ' AND feature = ?' query += ' AND feature = ?'
params.append(feature) params.append(feature)
@@ -527,7 +702,7 @@ def bot_logs():
message = data.get('message', '') message = data.get('message', '')
c.execute( c.execute(
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)', '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 # Prune: keep only the latest 50 per player/feature
c.execute(''' c.execute('''
@@ -538,7 +713,7 @@ def bot_logs():
WHERE player_id = ? AND feature = ? WHERE player_id = ? AND feature = ?
ORDER BY id DESC LIMIT 50 ORDER BY id DESC LIMIT 50
) )
''', (player_id, feature, player_id, feature)) ''', (player_key, feature, player_key, feature))
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -552,9 +727,12 @@ def bot_logs():
@dashboard.route('/dashboard/bootcamp-attack-now', methods=['POST']) @dashboard.route('/dashboard/bootcamp-attack-now', methods=['POST'])
def bootcamp_attack_now(): def bootcamp_attack_now():
player_id = (request.json or {}).get('player_id') player_id = (request.json or {}).get('player_id')
world_id = (request.json or {}).get('world_id', '')
if not player_id: if not player_id:
return jsonify({'error': 'missing player_id'}), 400 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 = get_db()
conn.execute(''' conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, '1', ?) 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-danger { background: #8b2222; color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 0.75rem; } .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 { #no-town-selected {
color: #666; color: #666;
font-size: 0.85rem; 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; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -325,9 +329,9 @@ tr:hover td { background: #1e1e40; }
align-items: center; align-items: center;
justify-content: 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; background: #16213e;
border: 2px solid #c8a44a; border: 2px solid #c8a44a;
border-radius: 10px; border-radius: 10px;
@@ -342,7 +346,7 @@ tr:hover td { background: #1e1e40; }
from { transform: scale(0.92); opacity: 0; } from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; } to { transform: scale(1); opacity: 1; }
} }
#building-modal-header, #academy-modal-header { #building-modal-header, #academy-modal-header, .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -350,12 +354,12 @@ tr:hover td { background: #1e1e40; }
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid #2a4a6a; 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; color: #c8a44a;
font-size: 1rem; font-size: 1rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
#building-modal-close, #academy-modal-close { #building-modal-close, #academy-modal-close, #unit-modal-close {
background: none; background: none;
border: none; border: none;
color: #888; color: #888;
@@ -364,7 +368,7 @@ tr:hover td { background: #1e1e40; }
line-height: 1; line-height: 1;
padding: 0 4px; 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 { #building-grid {
display: grid; display: grid;

View File

@@ -4,7 +4,7 @@
window.fetchTowns = async function() { window.fetchTowns = async function() {
try { 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.towns = await res.json();
window.renderTowns(); window.renderTowns();
window.updateServerStatus(true); window.updateServerStatus(true);
@@ -52,7 +52,7 @@ window.fetchClientStatus = async function() {
window.fetchLog = async function() { window.fetchLog = async function() {
try { 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(); const cmds = await res.json();
window.cmds = cmds; // Save globally so viewer can see reserved resources window.cmds = cmds; // Save globally so viewer can see reserved resources
if (window._logPanelMode === 'log') { if (window._logPanelMode === 'log') {
@@ -97,8 +97,8 @@ window.sendCommand = async function() {
const town = window.getSelectedTown(); const town = window.getSelectedTown();
if (!town) return alert('Select a town first.'); if (!town) return alert('Select a town first.');
const type = document.getElementById('cmd-type').value; const type = window.currentCmdType;
if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Command Type) πρώτα.'); if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Κατασκευή/Στρατός/Παζάρι/Έρευνα) πρώτα.');
let payload = {}; let payload = {};
@@ -132,7 +132,7 @@ window.sendCommand = async function() {
payload = { building_id }; payload = { building_id };
} else if (type === 'recruit') { } else if (type === 'recruit') {
const unit_id = document.getElementById('unit-select').value; const unit_id = window.selectedUnitId;
if (!unit_id) return alert('Παρακαλώ επιλέξτε Μονάδα προς εκπαίδευση.'); if (!unit_id) return alert('Παρακαλώ επιλέξτε Μονάδα προς εκπαίδευση.');
const amount = parseInt(document.getElementById('recruit-amount').value) || 1; const amount = parseInt(document.getElementById('recruit-amount').value) || 1;
@@ -182,6 +182,30 @@ window.sendCommand = async function() {
} }
payload = { research_id }; 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 { try {
@@ -221,18 +245,18 @@ window.cancelCommand = async function(id) {
window.fetchCaptchaStatus = async function() { window.fetchCaptchaStatus = async function() {
try { 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 data = await res.json();
const banner = document.getElementById('captcha-banner'); const banner = document.getElementById('captcha-banner');
if (!banner) return; if (!banner) return;
if (data.captcha_active) { if (data.captcha_active) {
// Only show it if the user hasn't explicitly clicked 'close' for this specific alert
if (banner.dataset.dismissed !== '1') { if (banner.dataset.dismissed !== '1') {
banner.style.display = 'flex'; banner.style.display = 'flex';
} }
} else { } else {
// Captcha cleared from the game - hide banner and reset dismiss state for next time
banner.style.display = 'none'; banner.style.display = 'none';
banner.dataset.dismissed = '0'; banner.dataset.dismissed = '0';
} }

View File

@@ -2,13 +2,106 @@
// Command Form Component // Command Form Component
// ================================================================ // ================================================================
window.onCmdTypeChange = function() { window.currentCmdType = null;
const type = document.getElementById('cmd-type').value;
document.getElementById('build-options').style.display = type === 'build' ? '' : 'none'; window.setCmdType = function(type, openModal = false) {
document.getElementById('recruit-options').style.display = type === 'recruit' ? '' : 'none'; window.currentCmdType = type;
document.getElementById('amount-group').style.display = type === 'recruit' ? '' : 'none';
document.getElementById('market-options').style.display = type === 'market_offer' ? '' : 'none'; // Update segmented control active state
document.getElementById('research-options').style.display = type === 'research' ? '' : 'none'; 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 // Building emoji icons for the visual grid
@@ -118,11 +211,8 @@ window.closeBuildingModal = function(e) {
window.selectBuilding = function(key, nameGr) { window.selectBuilding = function(key, nameGr) {
window.selectedBuildingId = key; window.selectedBuildingId = key;
// Update the trigger button label window.updateSelectionDisplay();
document.getElementById('selected-building-label').textContent = `🏗️ ${nameGr}`;
// Re-render grid to show new selection highlight
window.openBuildingModal(); window.openBuildingModal();
// Close after brief visual feedback
setTimeout(() => document.getElementById('building-modal-overlay').classList.remove('open'), 180); setTimeout(() => document.getElementById('building-modal-overlay').classList.remove('open'), 180);
}; };
@@ -233,77 +323,140 @@ window.closeAcademyModal = function(e) {
window.selectResearch = function(key, name) { window.selectResearch = function(key, name) {
window.selectedResearchId = key; window.selectedResearchId = key;
document.getElementById('selected-research-label').textContent = `🧪 ${name}`; window.updateSelectionDisplay();
window.openAcademyModal(); window.openAcademyModal();
setTimeout(() => document.getElementById('academy-modal-overlay').classList.remove('open'), 180); 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() { window.renderUnitDropdown = function() {
// No-op - selection now happens via modal
};
window.openUnitModal = function() {
const town = window.getSelectedTown(); const town = window.getSelectedTown();
if (!town) return; if (!town) return;
const uSelect = document.getElementById('unit-select'); const grid = document.getElementById('unit-grid');
const uData = town.unit_data || {}; const uData = town.unit_data || {};
const currentVal = uSelect.value; // Group units into categories for better display
uSelect.innerHTML = '<option value="" disabled selected>-- Επιλέξτε Μονάδα --</option>'; 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)) { let html = '';
if (key === 'militia') continue;
const data = uData[key]; for (const [catName, units] of Object.entries(categories)) {
let text = `${nameGr}`; let catHtml = '';
if (data) { for (const key of units) {
const w = window.fmt(data.wood || 0); if (key === 'militia') continue;
const st = window.fmt(data.stone || 0);
const i = window.fmt(data.iron || 0);
const pop = data.pop || 0;
// Unit build_time is usually raw seconds in GameData const nameGr = window.UNIT_NAMES_GR[key] || key;
let t = data.build_time || 0; const data = uData[key];
let tStr = `${t}s`; const icon = window.UNIT_ICONS[key] || '💂';
if (t > 60) { const isSelected = key === window.selectedUnitId;
let m = Math.floor(t / 60);
let s = t % 60;
tStr = `${m}m ${s}s`;
}
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 requiredGod = window.UNIT_GODS ? window.UNIT_GODS[key] : null;
const isLocked = missingKeys.length > 0; const isWrongGod = requiredGod && town.god !== requiredGod;
const isNoGod = key === 'godsent' && !town.god;
const option = document.createElement('option'); if (isWrongGod) {
option.value = key; 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) { const w = window.fmt(data.wood || 0);
option.textContent = `${text} — 🔒 Κλειδωμένο`; const st = window.fmt(data.stone || 0);
option.style.color = '#ff4444'; const i = window.fmt(data.iron || 0);
} else if (data.enough_resources === false) { const pop = data.pop || 0;
option.textContent = `${text} — ❌ ${costStr} (Λείπουν Πόροι 1x)`;
option.style.color = '#aa5555'; 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 { } 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); const onclick = clickable ? `onclick="window.selectUnit('${key}', '${nameGr}')"` : '';
} else {
const option = document.createElement('option'); catHtml += `<div class="bld-card ${cardClass}${isSelected ? ' bld-selected' : ''}" ${onclick} style="width:140px; justify-content:flex-start;">
option.value = key; <span class="bld-icon">${icon}</span>
option.textContent = text; <span class="bld-name" style="margin-top:6px; font-size:0.85rem;">${nameGr}</span>
uSelect.appendChild(option); <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)) { grid.innerHTML = html;
uSelect.value = currentVal; 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() { window.renderBuildQueuePreview = function() {
const town = window.getSelectedTown(); const town = window.getSelectedTown();
const el = document.getElementById('build-queue-preview'); const el = document.getElementById('build-queue-preview');

View File

@@ -12,8 +12,7 @@ window.renderTowns = function() {
// Get active filters // Get active filters
const searchTerm = (document.getElementById('town-search')?.value || '').toLowerCase(); const searchTerm = (document.getElementById('town-search')?.value || '').toLowerCase();
const reqFullWh = document.getElementById('filter-full-wh')?.checked; const reqFullWh = document.getElementById('filter-full-wh')?.checked;
const reqFestival = document.getElementById('filter-festival')?.checked; const reqNotBuilding = document.getElementById('filter-not-building')?.checked;
const reqPoints = document.getElementById('filter-points')?.checked;
const filteredTowns = window.towns.filter(t => { const filteredTowns = window.towns.filter(t => {
// 1. Search by name // 1. Search by name
@@ -29,13 +28,8 @@ window.renderTowns = function() {
// 2. Full WH (>95%) // 2. Full WH (>95%)
if (reqFullWh && Math.max(wPct, sPct, iPct) < 0.95) return false; if (reqFullWh && Math.max(wPct, sPct, iPct) < 0.95) return false;
// 3. Festival (Wood>15k, Stone>18k, Iron>15k) // 3. Not Building (no items in build_queue)
// City Festival exact costs = 15000, 18000, 15000 if (reqNotBuilding && t.build_queue && t.build_queue.length > 0) return false;
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;
return true; return true;
}); });
@@ -62,9 +56,6 @@ window.renderTowns = function() {
if (wPct >= 0.95 || sPct >= 0.95 || iPct >= 0.95) { if (wPct >= 0.95 || sPct >= 0.95 || iPct >= 0.95) {
markers += '<span title="Γεμάτη Αποθήκη!" style="margin-right:4px;">⚠️</span>'; 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;' : ''; 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>'; if(unitsHtml === '') unitsHtml = '<div style="color:#666">Κανένα στράτευμα</div>';
document.getElementById('td-units').innerHTML = unitsHtml; 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 = { window.UNIT_NAMES_GR = {
sword: "Ξιφομάχος", slinger: "Σφενδονήτης", archer: "Τοξότης", hoplite: "Οπλίτης", sword: "Ξιφομάχος", slinger: "Εκσφενδονιστής", archer: "Τοξότης", hoplite: "Οπλίτης",
rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης", rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης", godsent: "Θεόσταλτος",
big_transporter: "Μεταφορικό", small_transporter: "Γρήγ. Μεταφορικό", bireme: "Διήρης", big_transporter: "Μεταφορικό", small_transporter: "Γρήγ. Μεταφορικό", bireme: "Διήρης",
attack_ship: "Πλοίο Φάρος", trireme: "Τριήρης", colonize_ship: "Αποικιακό", attack_ship: "Πλοίο Φάρος", trireme: "Τριήρης", colonize_ship: "Αποικιακό",
medusa: "Μέδουσα", zyklop: "Κύκλωπας", harpy: "Άρπυια", pegasus: "Πήγασος", medusa: "Μέδουσα", zyklop: "Κύκλωπας", harpy: "Άρπυια", pegasus: "Πήγασος",
@@ -33,6 +33,17 @@ window.UNIT_NAMES_GR = {
hydra: "Ύδρα", sea_monster: "Τέρας Θάλασσας", militia: "Εθνοφρουρά" 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 = { window.RES_ICONS = {
wood: '<span class="res-icon res-wood" style="display:inline-block; margin-right:4px;"></span>', 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>', 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> <body>
<header> <header>
<h1><a href="/" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Back to Players">⬅️</a> ⚔️ Grepolis Remote</h1> <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"> <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="server-status" class="conn-badge">Server…</div>
<div id="client-status" class="conn-badge">Client…</div> <div id="client-status" class="conn-badge">Client…</div>
</div> </div>
@@ -29,7 +30,6 @@
<div id="town-panel"> <div id="town-panel">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h2 style="margin: 0;">Towns</h2> <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>
<div id="town-filters" style="margin-bottom: 15px; padding: 10px; background: #0f3460; border-radius: 6px; border: 1px solid #2a4a6a;"> <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>
<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;"> <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()"> 🎭 Ελεύθεροι Πόροι <input type="checkbox" id="filter-not-building" 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+ Πόντοι
</label> </label>
</div> </div>
</div> </div>
@@ -87,132 +83,31 @@
</div> </div>
<div id="command-form-wrap" style="display:none"> <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"> <!-- Segmented Control Row -->
<label>Command Type</label> <div class="segmented-control" id="cmd-type-buttons" style="display: flex; gap: 5px; background: #16213e; padding: 4px; border-radius: 8px; border: 1px solid #2a4a6a;">
<select id="cmd-type" onchange="onCmdTypeChange()"> <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>
<option value="" disabled selected>-- Επιλέξτε --</option> <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>
<option value="build">Build / Upgrade</option> <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>
<option value="recruit">Recruit Troops</option> <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>
<option value="market_offer">Παζάρι - Προσφορά</option> <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>
<option value="research">Ακαδημία - Έρευνες</option>
</select>
</div> </div>
<!-- Build options - now a button that opens the visual picker --> <!-- Dynamic Selection Area -->
<div class="form-group" id="build-options" style="display:none"> <div id="selection-area" style="display:none; align-items: center; gap: 10px; flex-wrap: wrap;">
<label>Building</label> <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;">
<button class="btn btn-gold" id="open-building-modal" onclick="window.openBuildingModal()" style="text-align:left; min-width:200px;"> <span id="selection-label">-- Επιλέξτε --</span>
<span id="selected-building-label">-- Επιλέξτε Κατασκευή --</span> </button>
</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> </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>
<div id="build-queue-preview"></div> <div id="build-queue-preview"></div>
@@ -233,6 +128,94 @@
</div> </div>
<!-- ====== Blueprints Modal ====== -->
<div class="modal-overlay" id="blueprints-modal-overlay" onclick="window.closeBlueprintsModal(event)">
<div class="custom-modal" id="blueprints-modal" style="max-width: 400px;">
<div class="modal-header">
<h3>📜 Επιλογή Blueprint</h3>
<button class="modal-close" onclick="window.closeBlueprintsModal()"></button>
</div>
<div style="padding: 15px;">
<p style="color:#ccc; font-size:0.85rem; margin-bottom:15px;">Επιλέξτε ένα Blueprint για να αναλάβει το Python την αυτόματη κατασκευή της πόλης.</p>
<div id="blueprint-list" style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
<!-- Currently just one blueprint -->
<div class="bld-card" id="bp-card-standard" onclick="window.selectBlueprint('Standard Growth')" style="width:100%; justify-content:flex-start; cursor:pointer;">
<span class="bld-icon" style="font-size:2rem;">🏙️</span>
<div style="display:flex; flex-direction:column; align-items:flex-start;">
<span class="bld-name" style="margin-top:0; font-size:1rem; font-weight:bold;">Standard Growth</span>
<span style="font-size:0.75rem; color:#888;">Αυτόματη ανάπτυξη κτιρίων & ακαδημίας</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ====== Market Modal ====== -->
<div class="modal-overlay" id="market-modal-overlay" onclick="window.closeMarketModal(event)">
<div class="custom-modal" id="market-modal" style="max-width: 500px;">
<div class="modal-header">
<h3>🛒 Ρυθμίσεις Παζαριού</h3>
<button class="modal-close" onclick="window.closeMarketModal()"></button>
</div>
<div style="padding: 15px;">
<div style="display:flex; gap:10px; margin-bottom:10px;">
<div style="flex:1;">
<label 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 ====== --> <!-- ====== Building Picker Modal ====== -->
<div id="building-modal-overlay" onclick="window.closeBuildingModal(event)"> <div id="building-modal-overlay" onclick="window.closeBuildingModal(event)">
<div id="building-modal"> <div id="building-modal">
@@ -247,6 +230,17 @@
</div> </div>
</div> </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 ====== --> <!-- ====== Academy Picker Modal ====== -->
<div id="academy-modal-overlay" onclick="window.closeAcademyModal(event)"> <div id="academy-modal-overlay" onclick="window.closeAcademyModal(event)">
<div id="academy-modal"> <div id="academy-modal">
@@ -324,6 +318,7 @@
</style> </style>
<script> <script>
window.PLAYER_ID = "{{ player_id }}"; window.PLAYER_ID = "{{ player_id }}";
window.WORLD_ID = "{{ world_id }}";
</script> </script>
<script src="/static/js/state.js?v=6"></script> <script src="/static/js/state.js?v=6"></script>
<script src="/static/js/components/townViewer.js?v=6"></script> <script src="/static/js/components/townViewer.js?v=6"></script>

View File

@@ -233,7 +233,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<a href="/player/{{ player_id }}">← Πίσω</a> <a href="/player/{{ player_id }}/{{ world_id }}">← Πίσω</a>
<h1>🌾 Farm Manager</h1> <h1>🌾 Farm Manager</h1>
<span class="online-dot" id="online-dot" title="Κατάσταση Script"></span> <span class="online-dot" id="online-dot" title="Κατάσταση Script"></span>
<button class="sync-btn" onclick="requestSync()">Live Sync</button> <button class="sync-btn" onclick="requestSync()">Live Sync</button>
@@ -411,6 +411,7 @@
<script> <script>
const PLAYER_ID = '{{ player_id }}'; const PLAYER_ID = '{{ player_id }}';
const WORLD_ID = '{{ world_id }}';
let selectedOption = 1; let selectedOption = 1;
// -- Loot option buttons -- // -- Loot option buttons --
@@ -445,7 +446,7 @@
fetch('/dashboard/farm-settings', { fetch('/dashboard/farm-settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(r => r.json())
.then(() => { .then(() => {
@@ -457,7 +458,7 @@
// -- Load current settings -- // -- Load current settings --
function loadSettings() { 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(r => r.json())
.then(cfg => { .then(cfg => {
document.getElementById('farm-enabled').checked = cfg.enabled; document.getElementById('farm-enabled').checked = cfg.enabled;
@@ -480,7 +481,7 @@
} }
function loadFarmData() { 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(r => r.json())
.then(resp => { .then(resp => {
const data = resp.towns || []; const data = resp.towns || [];
@@ -563,7 +564,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
player_id: PLAYER_ID, player_id: PLAYER_ID,
town_id: 0, town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
type: 'farm_upgrade', type: 'farm_upgrade',
payload: { threshold: threshold, action_type: actionType } payload: { threshold: threshold, action_type: actionType }
}) })
@@ -590,7 +591,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
player_id: PLAYER_ID, player_id: PLAYER_ID,
town_id: 0, town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
type: 'farm_loot', type: 'farm_loot',
payload: { loot_option: selectedOption } payload: { loot_option: selectedOption }
}) })
@@ -616,7 +617,7 @@
fetch('/dashboard/bootcamp-attack-now', { fetch('/dashboard/bootcamp-attack-now', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(r => r.json())
.then(() => { .then(() => {
@@ -632,7 +633,7 @@
async function checkWarehouseStatus() { async function checkWarehouseStatus() {
try { 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 data = await res.json();
const banner = document.getElementById('warehouse-full-banner'); const banner = document.getElementById('warehouse-full-banner');
if (banner) banner.style.display = data.warehouse_full ? 'flex' : 'none'; if (banner) banner.style.display = data.warehouse_full ? 'flex' : 'none';
@@ -650,7 +651,7 @@
} }
function loadBotSettings() { 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(r => r.json())
.then(cfg => { .then(cfg => {
document.getElementById('bootcamp-enabled').checked = !!cfg.bootcamp_enabled; document.getElementById('bootcamp-enabled').checked = !!cfg.bootcamp_enabled;
@@ -668,6 +669,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
player_id: PLAYER_ID, player_id: PLAYER_ID,
world_id: WORLD_ID,
bootcamp_enabled: document.getElementById('bootcamp-enabled').checked, bootcamp_enabled: document.getElementById('bootcamp-enabled').checked,
bootcamp_use_def: document.getElementById('bootcamp-use-def').checked, bootcamp_use_def: document.getElementById('bootcamp-use-def').checked,
rural_trade_enabled: document.getElementById('rural-trade-enabled').checked, rural_trade_enabled: document.getElementById('rural-trade-enabled').checked,
@@ -707,9 +709,9 @@
} }
function loadBotLogs() { 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)); .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)); .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::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); } .hub-card.farm:hover { border-color: #4acc64; box-shadow: 0 12px 40px rgba(74,200,100,0.15); }
/* Live Tracker — blue (coming soon, dimmed) */ /* Live Tracker — teal */
.hub-card.tracker { border-color: #1a2030; opacity: 0.65; cursor: not-allowed; } .hub-card.tracker { border-color: #1a3035; }
.hub-card.tracker:hover { transform: none; box-shadow: none; border-color: #1a2030; } .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 { .card-icon {
font-size: 2.8rem; font-size: 2.8rem;
@@ -154,24 +161,29 @@
<div class="hub-grid"> <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> <span class="card-icon">🏛️</span>
<div class="card-title">Admin Mode</div> <div class="card-title">Admin Mode</div>
<div class="card-desc">Κτίρια, στρατολόγηση, αγορά, ουρά κατασκευών και πλήρης έλεγχος πόλεων.</div> <div class="card-desc">Κτίρια, στρατολόγηση, αγορά, ουρά κατασκευών και πλήρης έλεγχος πόλεων.</div>
</a> </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> <span class="card-icon">🌾</span>
<div class="card-title">Farm Manager</div> <div class="card-title">Farm Manager</div>
<div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div> <div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div>
</a> </a>
<div class="hub-card tracker"> <a href="/player/{{ player_id }}/{{ world_id }}/tracker" class="hub-card tracker">
<span class="soon-badge">Σύντομα</span>
<span class="card-icon">🛡️</span> <span class="card-icon">🛡️</span>
<div class="card-title">Live Tracker</div> <div class="card-title">Live Tracker</div>
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</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> </div>
@@ -180,7 +192,8 @@
<script> <script>
// Fetch player name to show in the badge // Fetch player name to show in the badge
const playerId = '{{ player_id }}'; 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(r => r.json())
.then(towns => { .then(towns => {
if (towns && towns.length > 0) { if (towns && towns.length > 0) {

View File

@@ -123,7 +123,7 @@
{% endif %} {% endif %}
{% for p in players %} {% 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 style="display: flex; justify-content: space-between; align-items: center;">
<div> <div>
<strong>{{ p.player }}</strong> <strong>{{ p.player }}</strong>

View File

@@ -257,6 +257,7 @@
<thead> <thead>
<tr> <tr>
<th>Παίκτης</th> <th>Παίκτης</th>
<th>Κόσμος</th>
<th>Κατάσταση</th> <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-name">{{ m.player_name }}</div>
<div class="player-id">ID: {{ m.player_id }}</div> <div class="player-id">ID: {{ m.player_id }}</div>
</td> </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> <td>
{% if m.is_online %} {% if m.is_online %}
<span class="status-online">● Online</span> <span class="status-online">● Online</span>
@@ -279,7 +287,7 @@
</td> </td>
<td> <td>
{% if clan.owner_id == current_user.id %} {% 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"> <div class="toggle-group">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" name="farm" onchange="this.form.submit()" {{ 'checked' if m.feat_farm }}> 🌾 Farm <input type="checkbox" name="farm" onchange="this.form.submit()" {{ 'checked' if m.feat_farm }}> 🌾 Farm
@@ -287,20 +295,29 @@
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin <input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
</label> </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> </div>
</form> </form>
{% else %} {% else %}
<div class="toggle-group" style="opacity: 0.8;"> <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_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_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> </div>
{% endif %} {% endif %}
</td> </td>
<td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td> <td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td>
<td style="text-align:right;"> <td style="text-align:right;">
{% if clan.owner_id == current_user.id %} {% if clan.owner_id == current_user.id %}
<form method="POST" action="/auth/clan/remove-member/{{ m.player_id }}" <form method="POST" action="/auth/clan/remove-member/{{ m.player_id }}/{{ m.world_id }}"
onsubmit="return confirm('Αφαίρεση παίκτη {{ m.player_name }}?');"> onsubmit="return confirm('Αφαίρεση {{ m.player_name }} ({{ m.world_id }})?');">
<button type="submit" class="btn-danger">Αφαίρεση</button> <button type="submit" class="btn-danger">Αφαίρεση</button>
</form> </form>
{% endif %} {% 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>