Compare commits
37 Commits
efa63f761f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cc81b582 | |||
| a153b397d3 | |||
| 51485c0048 | |||
| cf23f38a6e | |||
| 11f30f4c6a | |||
| eb31072c87 | |||
| 47381a9304 | |||
| 76b991a62b | |||
| f5231a2524 | |||
| 84de7082ec | |||
| 83b8c85557 | |||
| d8ba139d07 | |||
| 6157ae1034 | |||
| 4272edf432 | |||
| 502b330ac5 | |||
| 1c65043eb3 | |||
| 5c4d415fdd | |||
| f22b92ae89 | |||
| 0f54ef9191 | |||
| 45e71ed90b | |||
| 66dcb71a8d | |||
| b36b11393f | |||
| 90ce6a029d | |||
| c9e6522f12 | |||
| 1cb5dca3c2 | |||
| 2552d3d075 | |||
| 5f6855ec69 | |||
| 05785c294e | |||
| 614029e527 | |||
| a572feef14 | |||
| b18e2e8f97 | |||
| 2769091b74 | |||
| f4a0e18686 | |||
| d6c2252f5c | |||
| bcda80e127 | |||
| 731a7b2f3b | |||
| f82893164e |
@@ -822,11 +822,12 @@
|
||||
async function pollAndExecute() {
|
||||
if (paused) return;
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id) return;
|
||||
|
||||
let cmdData;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}`);
|
||||
const res = await fetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}&world_id=${world_id}`);
|
||||
cmdData = await res.json();
|
||||
} catch (e) {
|
||||
log(`Poll failed: ${e}`);
|
||||
|
||||
10
app.py
10
app.py
@@ -5,6 +5,14 @@ from db import init_db, get_db
|
||||
from routes.api import api
|
||||
from routes.dashboard import dashboard
|
||||
from routes.auth import auth
|
||||
from routes.tracker import tracker
|
||||
from routes.attack_planner import attack_planner
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
# Initialise DB schema when the app starts
|
||||
init_db()
|
||||
@@ -63,6 +71,8 @@ def handle_options(path):
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(dashboard)
|
||||
app.register_blueprint(auth)
|
||||
app.register_blueprint(tracker)
|
||||
app.register_blueprint(attack_planner)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("✅ Grepolis Remote — DB initialised")
|
||||
|
||||
221
blueprint_engine.py
Normal file
221
blueprint_engine.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STANDARD_BLUEPRINT — ordered list of phases.
|
||||
# Each phase is a dict of { building_name: required_level }.
|
||||
# The engine works through phases in order, queueing one building at a time.
|
||||
# ---------------------------------------------------------------------------
|
||||
STANDARD_BLUEPRINT = [
|
||||
{"barracks": 1, "farm": 3, "lumber": 2, "stoner": 2, "ironer": 2, "storage": 2, "main": 2, "temple": 1},
|
||||
{"barracks": 1, "farm": 3, "lumber": 3, "stoner": 3, "ironer": 3, "storage": 6, "main": 8},
|
||||
{"farm": 8, "lumber": 8, "ironer": 8, "stoner": 8, "market": 5, "temple": 5, "barracks": 5},
|
||||
{"academy": 13},
|
||||
{"storage": 12, "farm": 12},
|
||||
{"main": 25},
|
||||
{"storage": 21, "farm": 15},
|
||||
{"lumber": 15, "stoner": 10, "ironer": 12},
|
||||
{"docks": 10},
|
||||
{"academy": 30},
|
||||
{"farm": 20, "storage": 25},
|
||||
{"market": 15, "trade_office": 1, "hide": 10},
|
||||
{"market": 30, "farm": 35, "thermal": 1, "academy": 36},
|
||||
{"farm": 45, "storage": 35, "lumber": 40, "ironer": 40, "stoner": 40},
|
||||
{"temple": 30}
|
||||
]
|
||||
|
||||
RESEARCH_LIST = [
|
||||
"booty", "pottery", "architecture", "building_crane",
|
||||
"shipwright", "plow", "mathematics", "combat_experience",
|
||||
"strong_wine", "take_over", "colonize_ship"
|
||||
]
|
||||
|
||||
RESEARCH_LEVELS = {
|
||||
"booty": 7,
|
||||
"pottery": 7,
|
||||
"architecture": 10,
|
||||
"building_crane": 13,
|
||||
"shipwright": 13,
|
||||
"plow": 22,
|
||||
"mathematics": 25,
|
||||
"combat_experience": 34,
|
||||
"strong_wine": 34,
|
||||
"take_over": 28,
|
||||
"colonize_ship": 13
|
||||
}
|
||||
|
||||
MAX_LOOKAHEAD_PHASES = 2 # How many phases ahead to look if current phase is fully blocked
|
||||
|
||||
|
||||
def evaluate_blueprints(conn):
|
||||
blueprints = conn.execute('SELECT town_id, blueprint_name FROM town_blueprints WHERE is_active = 1').fetchall()
|
||||
log.warning(f"[blueprint] Active blueprints: {len(blueprints)}")
|
||||
if not blueprints:
|
||||
return
|
||||
|
||||
for row in blueprints:
|
||||
town_id = str(row['town_id'])
|
||||
log.warning(f"[blueprint] Evaluating town_id={town_id}")
|
||||
|
||||
town_row = conn.execute(
|
||||
'SELECT data, player_id, town_name, world_id FROM town_state WHERE town_id = ?', (town_id,)
|
||||
).fetchone()
|
||||
|
||||
if not town_row:
|
||||
log.warning(f"[blueprint] No town_state row for town_id={town_id} — skipping")
|
||||
continue
|
||||
|
||||
player_id = town_row['player_id']
|
||||
town_name_db = town_row['town_name']
|
||||
town_world_id = town_row['world_id']
|
||||
log.warning(f"[blueprint] Town: {town_name_db}, player_id={player_id}, world_id={town_world_id!r}")
|
||||
|
||||
try:
|
||||
town = json.loads(town_row['data'])
|
||||
except Exception as e:
|
||||
log.warning(f"[blueprint] Failed to parse town JSON: {e}")
|
||||
continue
|
||||
|
||||
build_queue = town.get('buildingOrder', [])
|
||||
buildings = town.get('buildings', {})
|
||||
build_data = town.get('buildData', {})
|
||||
|
||||
log.warning(f"[blueprint] buildingOrder length: {len(build_queue)}")
|
||||
|
||||
# ── Guard: don't queue if there's already a pending/executing command ──────
|
||||
# Ghost detection: if a 'pending' command has sat untouched for >5 min,
|
||||
# it's stale — delete it so we can re-evaluate fresh.
|
||||
five_min_ago = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
|
||||
db_pending = conn.execute(
|
||||
"SELECT id, type, status, updated_at FROM commands "
|
||||
"WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')",
|
||||
(town_id,)
|
||||
).fetchall()
|
||||
|
||||
if db_pending:
|
||||
ghost_ids = [
|
||||
r['id'] for r in db_pending
|
||||
if r['status'] == 'pending' and (r['updated_at'] is None or r['updated_at'] < five_min_ago)
|
||||
]
|
||||
if ghost_ids:
|
||||
log.warning(f"[blueprint] Deleting {len(ghost_ids)} ghost commands {ghost_ids}")
|
||||
conn.execute(
|
||||
f"DELETE FROM commands WHERE id IN ({','.join('?' for _ in ghost_ids)})",
|
||||
ghost_ids
|
||||
)
|
||||
conn.commit()
|
||||
db_pending = conn.execute(
|
||||
"SELECT id, type, status, updated_at FROM commands "
|
||||
"WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')",
|
||||
(town_id,)
|
||||
).fetchall()
|
||||
|
||||
if db_pending:
|
||||
details = [(r['id'], r['type'], r['status']) for r in db_pending]
|
||||
log.warning(f"[blueprint] Already has {len(db_pending)} queued commands — skipping. {details}")
|
||||
continue
|
||||
|
||||
# ── Calculate future levels: current buildings + anything in the game build queue ──
|
||||
future_levels = {k: v for k, v in buildings.items()}
|
||||
for q_item in build_queue:
|
||||
b_type = q_item.get('building_type') or q_item.get('name')
|
||||
if b_type:
|
||||
future_levels[b_type] = future_levels.get(b_type, 0) + 1
|
||||
|
||||
log.warning(f"[blueprint] future_levels: {future_levels}")
|
||||
|
||||
# ── Simple phase search ────────────────────────────────────────────────────
|
||||
# Strategy:
|
||||
# 1. Find the first phase that has at least one building below its target.
|
||||
# 2. Queue the first incomplete building from that phase — no resource checks,
|
||||
# no dependency checks, exactly like adding it manually.
|
||||
# 3. If every incomplete building in that phase is flagged has_max_level=True
|
||||
# (meaning the game truly refuses to build it), look up to MAX_LOOKAHEAD_PHASES
|
||||
# phases ahead for something to queue instead.
|
||||
target_building = None
|
||||
lookahead_used = 0
|
||||
first_incomplete_phase_idx = None
|
||||
|
||||
for phase_idx, phase in enumerate(STANDARD_BLUEPRINT):
|
||||
# Collect buildings that still need work in this phase
|
||||
incomplete = [b for b, req in phase.items() if future_levels.get(b, 0) < req]
|
||||
|
||||
if not incomplete:
|
||||
continue # Phase is fully complete, move on
|
||||
|
||||
# Track the very first incomplete phase we encounter
|
||||
if first_incomplete_phase_idx is None:
|
||||
first_incomplete_phase_idx = phase_idx
|
||||
|
||||
log.warning(f"[blueprint] Phase {phase_idx} incomplete: {incomplete}")
|
||||
|
||||
# Separate into: genuinely blocked (has_max=True) vs. queueable
|
||||
blocked = [b for b in incomplete if build_data.get(b, {}).get('has_max_level', False)]
|
||||
queueable = [b for b in incomplete if b not in blocked]
|
||||
|
||||
if queueable:
|
||||
# We are in the current phase (or a valid lookahead phase) — queue it!
|
||||
target_building = queueable[0]
|
||||
log.warning(f"[blueprint] -> SELECTED '{target_building}' from phase {phase_idx}"
|
||||
+ (f" (lookahead +{lookahead_used})" if lookahead_used else ""))
|
||||
break
|
||||
|
||||
# Everything incomplete in this phase is has_max_level blocked.
|
||||
# Allow limited lookahead.
|
||||
if phase_idx == first_incomplete_phase_idx or lookahead_used < MAX_LOOKAHEAD_PHASES:
|
||||
if phase_idx != first_incomplete_phase_idx:
|
||||
lookahead_used += 1
|
||||
log.warning(f"[blueprint] Phase {phase_idx}: all incomplete buildings are has_max — "
|
||||
f"looking ahead (lookahead_used={lookahead_used})")
|
||||
continue
|
||||
|
||||
# Ran out of lookahead budget — stop
|
||||
log.warning(f"[blueprint] Lookahead exhausted after {lookahead_used} extra phases — giving up")
|
||||
break
|
||||
|
||||
# ── Academy Research (fallback when no building target) ───────────────────
|
||||
target_research = None
|
||||
if not target_building:
|
||||
academy_level = future_levels.get('academy', 0)
|
||||
researched = town.get('researches', {})
|
||||
for r_name in RESEARCH_LIST:
|
||||
if not researched.get(r_name):
|
||||
req_level = RESEARCH_LEVELS.get(r_name, 99)
|
||||
if academy_level >= req_level:
|
||||
target_research = r_name
|
||||
log.warning(f"[blueprint] -> Research target: {r_name}")
|
||||
break
|
||||
|
||||
log.warning(f"[blueprint] Final: target_building={target_building}, target_research={target_research}")
|
||||
|
||||
# ── Insert command ─────────────────────────────────────────────────────────
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if target_building:
|
||||
pos_row = conn.execute(
|
||||
"SELECT MAX(position) as max_pos FROM commands"
|
||||
" WHERE player_id = ? AND town_id = ? AND type = 'build'"
|
||||
" AND status IN ('pending', 'executing')",
|
||||
(str(player_id), str(town_id))
|
||||
).fetchone()
|
||||
position = (pos_row['max_pos'] or 0) + 1
|
||||
payload_str = json.dumps({"building_id": target_building})
|
||||
conn.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, position, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
''', (str(town_id), town_name_db, 'build', payload_str, position, now, now, str(player_id)))
|
||||
log.warning(f"[blueprint] Inserted build command: {target_building} for {town_name_db}")
|
||||
|
||||
elif target_research:
|
||||
payload_str = json.dumps({"research_id": target_research})
|
||||
conn.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
|
||||
''', (str(town_id), town_name_db, 'research', payload_str, now, now, str(player_id)))
|
||||
log.warning(f"[blueprint] Inserted research command: {target_research} for {town_name_db}")
|
||||
|
||||
else:
|
||||
log.warning(f"[blueprint] Nothing to do for {town_name_db}")
|
||||
@@ -14,15 +14,16 @@ function randInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
// Schedules fn to run after a random ms delay, then reschedules itself
|
||||
function jitterLoop(fn, minMs, maxMs) {
|
||||
function schedule() {
|
||||
// Schedules fn to run after a random ms delay, then reschedules itself.
|
||||
// Optional initialDelayMs sets the delay for the very first run only.
|
||||
function jitterLoop(fn, minMs, maxMs, initialDelayMs) {
|
||||
function schedule(delay) {
|
||||
setTimeout(async () => {
|
||||
await fn();
|
||||
schedule();
|
||||
}, randInt(minMs, maxMs));
|
||||
schedule(randInt(minMs, maxMs));
|
||||
}, delay);
|
||||
}
|
||||
schedule();
|
||||
schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs));
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -7,8 +7,9 @@ let captchaActive = false;
|
||||
|
||||
function reportCaptcha(detected) {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
if (!player_id) return;
|
||||
apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}`, {
|
||||
apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ detected })
|
||||
|
||||
@@ -17,13 +17,13 @@ let lastKnownBotSettings = {};
|
||||
// ----------------------------------------------------------------
|
||||
// botLog — sends a log entry to the server
|
||||
// ----------------------------------------------------------------
|
||||
async function botLog(player_id, feature, message) {
|
||||
async function botLog(player_id, world_id, feature, message) {
|
||||
log(`[${feature}] ${message}`);
|
||||
try {
|
||||
await apiFetch(`${BASE_URL}/api/bot-logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id, feature, message })
|
||||
body: JSON.stringify({ player_id, world_id, feature, message })
|
||||
});
|
||||
} catch (e) { /* non-critical */ }
|
||||
}
|
||||
@@ -38,7 +38,8 @@ async function autoBootcampLoop() {
|
||||
if (!settings.bootcamp_enabled) return;
|
||||
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (!player_id) return;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
let model;
|
||||
try {
|
||||
@@ -61,27 +62,30 @@ async function autoBootcampLoop() {
|
||||
const isFavor = reward.power_id?.includes('favor');
|
||||
const stashable = reward.stashable;
|
||||
|
||||
if (isInstant && !isFavor) {
|
||||
const useReward = () => {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'useReward',
|
||||
arguments: {}
|
||||
});
|
||||
await botLog(player_id, 'bootcamp', `Reward used: ${reward.power_id}`);
|
||||
botLog(player_id, world_id, 'bootcamp', `Reward used: ${reward.power_id}`);
|
||||
};
|
||||
|
||||
if (isInstant && !isFavor) {
|
||||
useReward();
|
||||
} else if (stashable) {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'stashReward',
|
||||
arguments: {}
|
||||
}, 0, {
|
||||
success: () => {
|
||||
botLog(player_id, world_id, 'bootcamp', `Reward stashed: ${reward.power_id}`);
|
||||
},
|
||||
error: useReward
|
||||
});
|
||||
await botLog(player_id, 'bootcamp', `Reward stashed: ${reward.power_id}`);
|
||||
} else {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'useReward',
|
||||
arguments: {}
|
||||
});
|
||||
await botLog(player_id, 'bootcamp', `Reward used (fallback): ${reward.power_id}`);
|
||||
useReward();
|
||||
}
|
||||
await sleep(randInt(3000, 7000));
|
||||
return; // Wait for next cycle to attack
|
||||
@@ -99,7 +103,7 @@ async function autoBootcampLoop() {
|
||||
const cooldown = model.getCooldownDuration();
|
||||
if (cooldown > 0) {
|
||||
const minRemaining = Math.round(cooldown / 60);
|
||||
await botLog(player_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
|
||||
await botLog(player_id, world_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ async function autoBootcampLoop() {
|
||||
if (movements) {
|
||||
for (const mv of Object.values(movements)) {
|
||||
if (mv.attributes.destination_is_attack_spot || mv.attributes.origin_is_attack_spot) {
|
||||
await botLog(player_id, 'bootcamp', 'Attack already in flight — skipping');
|
||||
await botLog(player_id, world_id, 'bootcamp', 'Attack already in flight — skipping');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +145,7 @@ async function autoBootcampLoop() {
|
||||
}
|
||||
|
||||
if (Object.keys(units).length === 0) {
|
||||
await botLog(player_id, 'bootcamp', 'No available units — skipping attack');
|
||||
await botLog(player_id, world_id, 'bootcamp', 'No available units — skipping attack. (Χωρίς αμυντικά)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,10 +156,10 @@ async function autoBootcampLoop() {
|
||||
});
|
||||
|
||||
const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', ');
|
||||
await botLog(player_id, 'bootcamp', `Attack sent — ${unitSummary}`);
|
||||
await botLog(player_id, world_id, 'bootcamp', `Στέλνω ${JSON.stringify(units)} στο camp...`);
|
||||
|
||||
} catch (e) {
|
||||
await botLog(player_id, 'bootcamp', `Error during attack: ${e}`);
|
||||
await botLog(player_id, world_id, 'bootcamp', `Error during attack: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +180,8 @@ async function autoRuralTradeLoop() {
|
||||
if (!settings.rural_trade_enabled) return;
|
||||
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (!player_id) return;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
const minRatio = RATIO_MAP[settings.rural_trade_ratio] ?? 0.75;
|
||||
|
||||
@@ -274,12 +279,12 @@ async function autoRuralTradeLoop() {
|
||||
arguments: { farm_town_id: farm.attributes.id, amount },
|
||||
town_id: parseInt(town_id_str)
|
||||
});
|
||||
await botLog(player_id, 'rural_trade',
|
||||
await botLog(player_id, world_id, 'rural_trade',
|
||||
`Traded ${amount} ${missingResource} ← ${farm.attributes.name} via ${town_obj.getName?.() ?? town_id_str}`);
|
||||
tradesTotal++;
|
||||
tradeMade = true;
|
||||
} catch (e) {
|
||||
await botLog(player_id, 'rural_trade', `Trade error: ${e}`);
|
||||
await botLog(player_id, world_id, 'rural_trade', `Trade error: ${e}`);
|
||||
}
|
||||
|
||||
await sleep(randInt(800, 1800));
|
||||
@@ -289,7 +294,7 @@ async function autoRuralTradeLoop() {
|
||||
}
|
||||
|
||||
if (!tradeMade && missingResource) {
|
||||
await botLog(player_id, 'rural_trade',
|
||||
await botLog(player_id, world_id, 'rural_trade',
|
||||
`${town_obj.getName?.() ?? town_id_str} needs ${missingResource} but no suitable village found`);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,18 @@ const LOOT_TIMINGS = { 1: 300000, 2: 1200000, 3: 5400000, 4: 14400000 };
|
||||
// Delay = loot_option cooldown + random 30-120s human jitter.
|
||||
// This mirrors ModernBot's pattern: run exactly when farms are ready.
|
||||
// ----------------------------------------------------------------
|
||||
function scheduleNextFarm() {
|
||||
function scheduleNextFarm(isFirstRun = false) {
|
||||
let totalMs = 15000; // 15 seconds for the first run to catch already-ready farms
|
||||
if (!isFirstRun) {
|
||||
const option = lastKnownFarmSettings.loot_option || 1;
|
||||
const baseMs = LOOT_TIMINGS[option] || 300000;
|
||||
const jitterMs = randInt(30000, 120000); // +30 to +120 s
|
||||
const totalMs = baseMs + jitterMs;
|
||||
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min (option ${option} + ${(jitterMs/1000).toFixed(0)}s jitter)`);
|
||||
totalMs = baseMs + jitterMs;
|
||||
}
|
||||
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min`);
|
||||
setTimeout(async () => {
|
||||
await autoFarmLoop();
|
||||
scheduleNextFarm();
|
||||
scheduleNextFarm(false);
|
||||
}, totalMs);
|
||||
}
|
||||
|
||||
@@ -37,11 +40,12 @@ function scheduleNextFarm() {
|
||||
async function pollAndExecute() {
|
||||
if (paused) return;
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id) return;
|
||||
|
||||
let cmdData;
|
||||
try {
|
||||
const res = await apiFetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}`);
|
||||
const res = await apiFetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}&world_id=${world_id}`);
|
||||
cmdData = await res.json();
|
||||
} catch (e) {
|
||||
log(`Poll failed: ${e}`);
|
||||
@@ -184,7 +188,8 @@ async function autoFarmLoop() {
|
||||
if (allFull) {
|
||||
log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle');
|
||||
try {
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}`, {
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: true })
|
||||
@@ -199,7 +204,8 @@ async function autoFarmLoop() {
|
||||
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
|
||||
// Report success so dashboard shows last_farmed_at
|
||||
try {
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}`, {
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: false })
|
||||
@@ -222,15 +228,23 @@ function boot() {
|
||||
detectCaptcha();
|
||||
setTimeout(pushState, 5000);
|
||||
jitterLoop(pushState, 60000, 120000); // state sync every 1–2 min
|
||||
jitterLoop(pollAndExecute, 8000, 18000); // command poll every 8–18 s
|
||||
scheduleNextFarm(); // auto-farm timer-based (loot_option + 30–120s)
|
||||
jitterLoop(pollAndExecute, 8000, 18000, 2000); // command poll every 8–18 s, but start in 2s
|
||||
scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30–120s)
|
||||
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 12–22 min
|
||||
jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 25–45 min
|
||||
if (typeof window._grcInitTracker === 'function') {
|
||||
window._grcInitTracker(); // live tracker event-driven
|
||||
}
|
||||
if (typeof window._grcInitAttackPlanner === 'function') {
|
||||
window._grcInitAttackPlanner(); // attack planner countdown engine
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
// Page already loaded (normal case when eval()'d dynamically)
|
||||
boot();
|
||||
// Page already loaded (normal case when eval()'d dynamically).
|
||||
// Use setTimeout so the rest of the concatenated modules (like 06 and 07)
|
||||
// have a chance to evaluate and expose their init functions before boot runs.
|
||||
setTimeout(boot, 0);
|
||||
} else {
|
||||
// Fallback: wait for load event (shouldn't happen but safe to keep)
|
||||
window.addEventListener('load', boot);
|
||||
|
||||
172
bot_modules/06_tracker.js
Normal file
172
bot_modules/06_tracker.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// ================================================================
|
||||
// 06_tracker.js — Live Tracker: movement & attack monitoring
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log)
|
||||
//
|
||||
// Strategy (Option B — event-driven + one initial load):
|
||||
// 1. On boot: read current movements from game memory, push to backend
|
||||
// 2. On GameEvents.attack.incoming or GameEvents.command.change:
|
||||
// re-read movements, push to backend (backend notifies SSE clients)
|
||||
//
|
||||
// Data source: CommandsMenuBubble (Grepolis internal Backbone model)
|
||||
// - Already used by the Sound Alarm script — proven safe
|
||||
// - Zero extra server requests to Grepolis
|
||||
// - Contains ALL movement types: incoming attacks, own attacks, support
|
||||
//
|
||||
// All pushes go to POST /api/<world_id>/movements with X-Clan-Key.
|
||||
// Backend is fully isolated per player_id + world_id.
|
||||
// ================================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal state — prevent overlapping pushes
|
||||
// ----------------------------------------------------------------
|
||||
let _trackerPushPending = false;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _extractMovements — reads CommandsMenuBubble from game memory
|
||||
// Returns a clean array of movement objects safe to send to backend.
|
||||
//
|
||||
// Source: Sound Alarm script already validated this model works.
|
||||
// We read .commands which is a list of all active troop movements.
|
||||
// ----------------------------------------------------------------
|
||||
function _extractMovements() {
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (!player_id) return [];
|
||||
|
||||
// CommandsMenuBubble holds all movement commands for the player
|
||||
const cmb = uw.MM.checkAndPublishRawModel('CommandsMenuBubble', { id: player_id });
|
||||
if (!cmb) return [];
|
||||
|
||||
const commands = cmb.get('commands') || [];
|
||||
const movements = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const attrs = cmd.attributes || cmd;
|
||||
if (!attrs) continue;
|
||||
|
||||
// Normalise command type to a readable key
|
||||
const cmdType = _normaliseType(attrs.type || attrs.command_type || '');
|
||||
|
||||
movements.push({
|
||||
id: String(attrs.id || attrs.command_id || ''),
|
||||
type: cmdType,
|
||||
origin_town: attrs.origin_town_name || attrs.origin?.town_name || null,
|
||||
origin_player: attrs.origin_player_name|| attrs.origin?.player_name|| null,
|
||||
target_town: attrs.target_town_name || attrs.target?.town_name || null,
|
||||
target_player: attrs.target_player_name|| attrs.target?.player_name|| null,
|
||||
// arrival_at is a Unix timestamp (seconds)
|
||||
arrival_at: attrs.arrival_at || attrs.arrival || null,
|
||||
});
|
||||
}
|
||||
|
||||
return movements.filter(m => m.id); // drop any without an ID
|
||||
|
||||
} catch (e) {
|
||||
log(`[tracker] Extract error: ${e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _normaliseType — maps game's internal type strings to clean keys
|
||||
// ----------------------------------------------------------------
|
||||
function _normaliseType(raw) {
|
||||
const t = String(raw).toLowerCase();
|
||||
if (t.includes('attack') && t.includes('sea')) return 'attack_sea';
|
||||
if (t.includes('attack') && t.includes('land')) return 'attack_land';
|
||||
if (t.includes('attack')) return 'attack_land';
|
||||
if (t.includes('support')) return 'support';
|
||||
if (t.includes('farm') || t.includes('loot')) return 'farming';
|
||||
if (t.includes('spy') || t.includes('espion')) return 'espionage';
|
||||
if (t.includes('settle') || t.includes('colon'))return 'colonization';
|
||||
return t || 'unknown';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _pushMovements — reads memory, sends to backend
|
||||
// Debounced: if a push is already in-flight, skip.
|
||||
// ----------------------------------------------------------------
|
||||
async function _pushMovements() {
|
||||
if (_trackerPushPending) return;
|
||||
_trackerPushPending = true;
|
||||
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
const movements = _extractMovements();
|
||||
log(`[tracker] Pushing ${movements.length} movement(s) for ${world_id}`);
|
||||
|
||||
await apiFetch(`${BASE_URL}/api/${world_id}/movements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id, world_id, movements })
|
||||
});
|
||||
} catch (e) {
|
||||
log(`[tracker] Push failed: ${e}`);
|
||||
} finally {
|
||||
_trackerPushPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// initTracker — called from boot() after game is ready
|
||||
//
|
||||
// 1. Immediate push (Option B initial load)
|
||||
// 2. Bind GameEvents for passive real-time updates
|
||||
// ----------------------------------------------------------------
|
||||
function initTracker() {
|
||||
// Wait a moment for the game models to fully initialise
|
||||
setTimeout(async () => {
|
||||
// --- Initial load push ---
|
||||
await _pushMovements();
|
||||
|
||||
// --- Bind to GameEvents (passive, zero server cost) ---
|
||||
try {
|
||||
// New incoming attack detected (This event works according to logs)
|
||||
const attackEvent = uw.GameEvents?.attack?.incoming || 'attack:incoming';
|
||||
uw.$.Observer(attackEvent).subscribe(
|
||||
'GRC_TRACKER_ATTACK',
|
||||
function(e, data) {
|
||||
// Small delay so game model updates before we read it
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to attack.incoming event');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to attack.incoming: ${e}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Any command state changed (sent, landed, recalled, etc.)
|
||||
// Fallback to string if the constant doesn't exist in this version
|
||||
const cmdEvent = (uw.GameEvents?.command && uw.GameEvents.command.change)
|
||||
? uw.GameEvents.command.change
|
||||
: 'CommandsMenuBubble:change';
|
||||
|
||||
uw.$.Observer(cmdEvent).subscribe(
|
||||
'GRC_TRACKER_CMD',
|
||||
function(e, data) {
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to command changes');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to command changes: ${e}`);
|
||||
}
|
||||
|
||||
// --- Failsafe: push every 15 seconds regardless of events ---
|
||||
setInterval(_pushMovements, 15000);
|
||||
|
||||
}, 6000); // 6s after boot — ensures CommandsMenuBubble model is loaded
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Expose initTracker so 05_main.js boot() can call it
|
||||
// ----------------------------------------------------------------
|
||||
window._grcInitTracker = initTracker;
|
||||
|
||||
})();
|
||||
245
bot_modules/07_attack_planner.js
Normal file
245
bot_modules/07_attack_planner.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ================================================================
|
||||
// 07_attack_planner.js — Coordinated Timed Attack Executor
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log, sleep)
|
||||
//
|
||||
// Flow:
|
||||
// 1. Every ~10s polls /api/<world>/attack_plans/active
|
||||
// 2. For each active participant assigned to this player:
|
||||
// a. Measures clock offset via /api/server_time (once per plan)
|
||||
// b. Sets a precision countdown using setTimeout
|
||||
// c. Shows a toolbar countdown badge in Grepolis
|
||||
// d. At T=0: opens attack window, pre-fills units, fires
|
||||
// e. Reports status back to backend
|
||||
//
|
||||
// Code borrowed from: attack-planner.js (switchTownForAttack, loadUnits)
|
||||
// ================================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// Track armed plans so we don't re-arm on every poll
|
||||
const _armedPlans = {}; // key: `${plan_id}_${origin_town_id}` → true
|
||||
let _clockOffset = null; // ms to add to Date.now() to get server time
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _measureClockOffset — one GET to /api/server_time, measures drift
|
||||
// Returns offset in ms (server_ms - client_ms at midpoint)
|
||||
// ----------------------------------------------------------------
|
||||
async function _measureClockOffset() {
|
||||
try {
|
||||
const before = Date.now();
|
||||
const res = await apiFetch(`${BASE_URL}/api/server_time`);
|
||||
const after = Date.now();
|
||||
const data = await res.json();
|
||||
const latency = (after - before) / 2;
|
||||
_clockOffset = data.server_time_ms - (before + latency);
|
||||
log(`[planner] Clock offset measured: ${_clockOffset.toFixed(0)}ms`);
|
||||
} catch (e) {
|
||||
_clockOffset = 0;
|
||||
log(`[planner] Clock sync failed, using 0 offset: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _serverNow — current time adjusted by measured clock offset
|
||||
// ----------------------------------------------------------------
|
||||
function _serverNow() {
|
||||
return Date.now() + (_clockOffset || 0);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _openAttackWindow — borrowed from attack-planner.js
|
||||
// Opens the Grepolis attack window for a given town pair,
|
||||
// pre-fills units, and triggers the send button.
|
||||
// This is identical to what a human player does manually.
|
||||
// ----------------------------------------------------------------
|
||||
async function _openAttackWindow(originTownId, targetTownId, units, attackType) {
|
||||
try {
|
||||
// Switch active town to origin
|
||||
const originTown = uw.ITowns?.towns?.[originTownId];
|
||||
if (!originTown) throw new Error(`Town ${originTownId} not found`);
|
||||
|
||||
if (uw.ITowns.getCurrentTown()?.id !== originTownId) {
|
||||
uw.ITowns.setCurrentTown(originTown);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
// Open Place (agora) window — this is where attacks are sent from
|
||||
const wndType = attackType === 'attack_sea'
|
||||
? uw.GPWindowMgr.TYPE_PLACE
|
||||
: uw.GPWindowMgr.TYPE_PLACE;
|
||||
|
||||
uw.GPWindowMgr.Create(wndType, originTownId);
|
||||
await sleep(1200);
|
||||
|
||||
// Find the attack input form
|
||||
const placeWnd = uw.GPWindowMgr.getOpenedWindow(wndType);
|
||||
if (!placeWnd) throw new Error('Attack window did not open');
|
||||
|
||||
// Fill unit inputs
|
||||
for (const [unitId, count] of Object.entries(units)) {
|
||||
if (!count || count <= 0) continue;
|
||||
const input = placeWnd.getJQElement()
|
||||
.find(`input[name="${unitId}"], input[id="${unitId}"]`)
|
||||
.first();
|
||||
if (input.length) {
|
||||
input.val(count).trigger('change').trigger('input');
|
||||
}
|
||||
}
|
||||
await sleep(400);
|
||||
|
||||
log(`[planner] ✅ Attack window filled for town ${originTownId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log(`[planner] ❌ Failed to open attack window: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _reportStatus — tell backend what happened
|
||||
// ----------------------------------------------------------------
|
||||
async function _reportStatus(worldId, planId, townId, status) {
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
await apiFetch(
|
||||
`${BASE_URL}/api/${worldId}/attack_plans/${planId}/participants/${townId}/status`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, player_id }),
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
log(`[planner] reportStatus failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _armParticipant — set up the countdown for one attack
|
||||
// ----------------------------------------------------------------
|
||||
async function _armParticipant(plan, worldId) {
|
||||
const key = `${plan.plan_id}_${plan.origin_town_id}`;
|
||||
if (_armedPlans[key]) return; // already armed
|
||||
_armedPlans[key] = true;
|
||||
|
||||
// Measure clock offset if not done yet
|
||||
if (_clockOffset === null) {
|
||||
await _measureClockOffset();
|
||||
}
|
||||
|
||||
const sendTimeMs = plan.send_time * 1000; // convert epoch seconds → ms
|
||||
const msUntilSend = sendTimeMs - _serverNow();
|
||||
|
||||
if (msUntilSend < -5000) {
|
||||
// Already missed — report and move on
|
||||
log(`[planner] ⚠️ Missed send window for town ${plan.origin_town_name} (${msUntilSend}ms late)`);
|
||||
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'missed');
|
||||
delete _armedPlans[key];
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[planner] ⏱ Armed: ${plan.origin_town_name} → ${plan.target_town_name} in ${(msUntilSend/1000).toFixed(1)}s`);
|
||||
|
||||
// Report armed status to backend
|
||||
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'armed');
|
||||
|
||||
// Update toolbar badge
|
||||
_updateBadge(plan.origin_town_name, plan.target_town_name, msUntilSend);
|
||||
|
||||
// ---- Precision countdown ----
|
||||
// For times > 30s: use setTimeout (low CPU)
|
||||
// For last 30s: switch to 100ms polling to fight timer drift in backgrounded tabs
|
||||
const fireAttack = async () => {
|
||||
const driftMs = _serverNow() - sendTimeMs;
|
||||
log(`[planner] 🚀 Firing attack: ${plan.origin_town_name} → ${plan.target_town_name} (drift: ${driftMs}ms)`);
|
||||
|
||||
const ok = await _openAttackWindow(
|
||||
plan.origin_town_id,
|
||||
plan.target_town_id || null,
|
||||
plan.units || {},
|
||||
plan.attack_type
|
||||
);
|
||||
|
||||
await _reportStatus(
|
||||
worldId, plan.plan_id, plan.origin_town_id,
|
||||
ok ? 'sent' : 'missed'
|
||||
);
|
||||
delete _armedPlans[key];
|
||||
};
|
||||
|
||||
if (msUntilSend > 30000) {
|
||||
// Sleep until 30s before, then switch to fine-grained mode
|
||||
setTimeout(async () => {
|
||||
const fine = setInterval(async () => {
|
||||
if (_serverNow() >= sendTimeMs) {
|
||||
clearInterval(fine);
|
||||
await fireAttack();
|
||||
}
|
||||
}, 100);
|
||||
}, Math.max(0, msUntilSend - 30000));
|
||||
} else {
|
||||
// Already within 30s — go straight to fine-grained
|
||||
const fine = setInterval(async () => {
|
||||
if (_serverNow() >= sendTimeMs) {
|
||||
clearInterval(fine);
|
||||
await fireAttack();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _updateBadge — shows a toolbar countdown (reuses existing GRC indicator)
|
||||
// ----------------------------------------------------------------
|
||||
function _updateBadge(originName, targetName, msLeft) {
|
||||
const secs = Math.ceil(msLeft / 1000);
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
const cd = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
log(`[planner] 🎯 ${originName} → ${targetName} T-${cd}`);
|
||||
// The toolbar element is managed by 01_ui.js — we just log for now.
|
||||
// A future update can inject a dedicated DOM widget.
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// pollActivePlans — checks backend every ~10s for active plans
|
||||
// ----------------------------------------------------------------
|
||||
async function pollActivePlans() {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`${BASE_URL}/api/${world_id}/attack_plans/active?player_id=${player_id}`
|
||||
);
|
||||
const plans = await res.json();
|
||||
|
||||
if (!Array.isArray(plans) || plans.length === 0) return;
|
||||
|
||||
log(`[planner] Found ${plans.length} active plan participant(s)`);
|
||||
for (const plan of plans) {
|
||||
await _armParticipant(plan, world_id);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`[planner] Poll failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// initAttackPlanner — called from boot()
|
||||
// ----------------------------------------------------------------
|
||||
function initAttackPlanner() {
|
||||
// Start polling after 8s (game models settled)
|
||||
setTimeout(() => {
|
||||
pollActivePlans();
|
||||
// Then poll every 10-15s with jitter
|
||||
jitterLoop(pollActivePlans, 10000, 15000);
|
||||
log('[planner] ✅ Attack planner module active');
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
window._grcInitAttackPlanner = initAttackPlanner;
|
||||
|
||||
})();
|
||||
118
db.py
118
db.py
@@ -92,7 +92,86 @@ def init_db():
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_bot_logs_player_feature ON bot_logs(player_id, feature)')
|
||||
|
||||
# Blueprints - assigns a blueprint to a specific town
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS town_blueprints (
|
||||
town_id TEXT PRIMARY KEY,
|
||||
blueprint_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
|
||||
# Troop movements — pushed by Tampermonkey from game events
|
||||
# Fully isolated per player_id + world_id.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS movements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
command_id TEXT NOT NULL,
|
||||
cmd_type TEXT NOT NULL,
|
||||
origin_town TEXT,
|
||||
origin_player TEXT,
|
||||
target_town TEXT,
|
||||
target_player TEXT,
|
||||
arrival_at INTEGER,
|
||||
raw_data TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(player_id, world_id, command_id)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)')
|
||||
|
||||
# Attack Plans — coordinated timed strikes across multiple players/towns
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS attack_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
world_id TEXT NOT NULL,
|
||||
plan_name TEXT NOT NULL,
|
||||
created_by_player_id TEXT NOT NULL,
|
||||
target_town_id TEXT,
|
||||
target_town_name TEXT,
|
||||
target_x REAL,
|
||||
target_y REAL,
|
||||
target_arrival_time INTEGER NOT NULL, -- unix epoch (UTC)
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
-- draft | active | completed | cancelled
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_attack_plans_world ON attack_plans(world_id, status)')
|
||||
|
||||
# Attack Plan Participants — one row per attacking town per plan
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS attack_plan_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL REFERENCES attack_plans(id) ON DELETE CASCADE,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
origin_town_id TEXT NOT NULL,
|
||||
origin_town_name TEXT,
|
||||
units TEXT NOT NULL DEFAULT '{}', -- JSON
|
||||
attack_type TEXT, -- 'attack_land' | 'attack_sea'
|
||||
transport_needed INTEGER NOT NULL DEFAULT 0,
|
||||
transport_count INTEGER NOT NULL DEFAULT 0,
|
||||
travel_time_secs INTEGER,
|
||||
send_time INTEGER, -- unix epoch (UTC), calculated
|
||||
return_time INTEGER, -- unix epoch (UTC), calculated
|
||||
is_feasible INTEGER NOT NULL DEFAULT 1,
|
||||
error_msg TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- pending | armed | sent | missed | cancelled
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(plan_id, origin_town_id)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_app_plan ON attack_plan_participants(plan_id)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_app_player ON attack_plan_participants(player_id, world_id)')
|
||||
|
||||
# Migration: add new columns if upgrading an existing database
|
||||
|
||||
for _col in [
|
||||
'ALTER TABLE town_state ADD COLUMN player_id TEXT',
|
||||
'ALTER TABLE town_state ADD COLUMN alliance_id TEXT',
|
||||
@@ -103,6 +182,7 @@ def init_db():
|
||||
'ALTER TABLE commands ADD COLUMN position INTEGER',
|
||||
'ALTER TABLE farm_settings ADD COLUMN bandit_camp_enabled INTEGER NOT NULL DEFAULT 0',
|
||||
"ALTER TABLE clan_members ADD COLUMN features TEXT NOT NULL DEFAULT 'farm,admin'",
|
||||
'ALTER TABLE clan_members ADD COLUMN world_id TEXT',
|
||||
'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)',
|
||||
]:
|
||||
try:
|
||||
@@ -138,19 +218,53 @@ def init_db():
|
||||
)
|
||||
''')
|
||||
|
||||
# Clan members — links Grepolis player_ids to a clan
|
||||
# Clan members — links Grepolis player_ids to a clan.
|
||||
# UNIQUE on (clan_id, player_id, world_id) so the same player
|
||||
# appearing in multiple worlds creates separate rows.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS clan_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clan_id INTEGER NOT NULL REFERENCES clans(id),
|
||||
player_id TEXT NOT NULL,
|
||||
player_name TEXT,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
features TEXT NOT NULL DEFAULT 'farm,admin',
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(clan_id, player_id)
|
||||
UNIQUE(clan_id, player_id, world_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Migration: if clan_members still has the old UNIQUE(clan_id, player_id) constraint
|
||||
# (without world_id), recreate the table with the correct 3-column constraint.
|
||||
try:
|
||||
tbl_sql = c.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='clan_members'"
|
||||
).fetchone()
|
||||
if tbl_sql and 'player_id, world_id' not in (tbl_sql['sql'] or ''):
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS _clan_members_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clan_id INTEGER NOT NULL REFERENCES clans(id),
|
||||
player_id TEXT NOT NULL,
|
||||
player_name TEXT,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
features TEXT NOT NULL DEFAULT 'farm,admin',
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(clan_id, player_id, world_id)
|
||||
)
|
||||
''')
|
||||
c.execute('''
|
||||
INSERT OR IGNORE INTO _clan_members_new
|
||||
(id, clan_id, player_id, player_name, world_id, features, joined_at)
|
||||
SELECT id, clan_id, player_id, player_name,
|
||||
COALESCE(world_id, ''), features, joined_at
|
||||
FROM clan_members
|
||||
''')
|
||||
c.execute('DROP TABLE clan_members')
|
||||
c.execute('ALTER TABLE _clan_members_new RENAME TO clan_members')
|
||||
except Exception as _e:
|
||||
print(f'clan_members migration skipped: {_e}')
|
||||
|
||||
# Migration: Auto-assign existing users to their clan_id if they are the owner
|
||||
try:
|
||||
c.execute('''
|
||||
|
||||
111
future_ideas.md
Normal file
111
future_ideas.md
Normal 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.
|
||||
```
|
||||
152
routes/api.py
152
routes/api.py
@@ -1,9 +1,13 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from db import get_db
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from flask import make_response
|
||||
from blueprint_engine import evaluate_blueprints
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
api = Blueprint('api', __name__)
|
||||
|
||||
@@ -25,17 +29,18 @@ def _get_clan_from_request():
|
||||
# ------------------------------------------------------------------
|
||||
# Helper — auto-register a player_id under a clan on first push.
|
||||
# ------------------------------------------------------------------
|
||||
def _auto_register_member(clan_id, player_id, player_name):
|
||||
def _auto_register_member(clan_id, player_id, player_name, world_id=''):
|
||||
world_id = world_id or ''
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name)
|
||||
VALUES (?, ?, ?)
|
||||
''', (clan_id, str(player_id), player_name or ''))
|
||||
# Update name in case it changed
|
||||
INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name, world_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (clan_id, str(player_id), player_name or '', world_id))
|
||||
# Update name on every push (it can change); world_id is part of the key so no overwrite risk
|
||||
conn.execute('''
|
||||
UPDATE clan_members SET player_name = ?
|
||||
WHERE clan_id = ? AND player_id = ?
|
||||
''', (player_name or '', clan_id, str(player_id)))
|
||||
WHERE clan_id = ? AND player_id = ? AND world_id = ?
|
||||
''', (player_name or '', clan_id, str(player_id), world_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -59,7 +64,7 @@ def receive_state():
|
||||
# Auto-register this player to the clan that matches the key (if any)
|
||||
clan = _get_clan_from_request()
|
||||
if clan:
|
||||
_auto_register_member(clan['id'], player_id, player)
|
||||
_auto_register_member(clan['id'], player_id, player, world_id)
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
@@ -94,24 +99,60 @@ def receive_state():
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Store world speed + unit data for attack planner calculations
|
||||
world_speed = data.get('world_speed')
|
||||
unit_speeds = data.get('unit_speeds')
|
||||
if world_id and world_speed is not None and unit_speeds:
|
||||
world_data = json.dumps({'world_speed': world_speed, 'unit_speeds': unit_speeds})
|
||||
c.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
''', (f'world_data_{world_id}', world_data, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
evaluate_blueprints(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print("Error evaluating blueprints:", e)
|
||||
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'towns_updated': len(towns)})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/commands/pending
|
||||
# Tampermonkey polls this to get the next command to execute.
|
||||
# Returns one 'build' AND one 'recruit' command independently,
|
||||
# so both queues are served in parallel without blocking each other.
|
||||
# ------------------------------------------------------------------
|
||||
def _fetch_pending_of_type(c, cmd_type, player_id):
|
||||
def _fetch_pending_of_type(c, cmd_type, player_id, world_id):
|
||||
"""Fetch a single oldest pending command of a given type (recruit, market, etc.)."""
|
||||
|
||||
# We use LEFT JOIN because global commands (like farm_loot) use a pseudo town_id like "0_gr121"
|
||||
# which does not exist in town_state.
|
||||
global_town_id = f"0_{world_id}" if world_id else "0"
|
||||
|
||||
if world_id:
|
||||
row = c.execute('''
|
||||
SELECT c.* FROM commands c
|
||||
LEFT JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'pending' AND c.type = ? AND c.player_id = ?
|
||||
AND (ts.world_id = ? OR c.town_id = ?)
|
||||
ORDER BY c.updated_at ASC, c.id ASC
|
||||
LIMIT 1
|
||||
''', (cmd_type, player_id, world_id, global_town_id)).fetchone()
|
||||
else:
|
||||
row = c.execute('''
|
||||
SELECT * FROM commands
|
||||
WHERE status = 'pending' AND type = ? AND player_id = ?
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''', (cmd_type, player_id)).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
c.execute('''
|
||||
@@ -127,15 +168,43 @@ def _fetch_pending_of_type(c, cmd_type, player_id):
|
||||
}
|
||||
|
||||
|
||||
def _fetch_pending_builds_all_towns(c, player_id):
|
||||
def _fetch_pending_builds_all_towns(c, player_id, world_id):
|
||||
"""
|
||||
Fetch ONE pending 'build' command per distinct town_id.
|
||||
This allows all towns to build in parallel within a single poll cycle.
|
||||
Within each town the oldest-updated command is picked first, so requeued
|
||||
commands (updated_at = now) naturally sort behind fresh ones.
|
||||
|
||||
Towns that already have a command in 'executing' state are skipped —
|
||||
this prevents a second build from being dispatched before the first one
|
||||
has reported its result (which was causing commands to pile up in EXECUTING).
|
||||
"""
|
||||
# Towns that currently have a build already in-flight — don't touch those.
|
||||
if world_id:
|
||||
executing_rows = c.execute('''
|
||||
SELECT DISTINCT c.town_id FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'executing' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
|
||||
''', (player_id, world_id)).fetchall()
|
||||
else:
|
||||
executing_rows = c.execute('''
|
||||
SELECT DISTINCT town_id FROM commands
|
||||
WHERE status = 'executing' AND type = 'build' AND player_id = ?
|
||||
''', (player_id,)).fetchall()
|
||||
busy_towns = {r['town_id'] for r in executing_rows}
|
||||
|
||||
# Get every town that has at least one pending build, ordered by
|
||||
# which town has been waiting longest (MIN updated_at across its commands).
|
||||
if world_id:
|
||||
town_rows = c.execute('''
|
||||
SELECT c.town_id
|
||||
FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'pending' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
|
||||
GROUP BY c.town_id
|
||||
ORDER BY MIN(c.updated_at) ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
else:
|
||||
town_rows = c.execute('''
|
||||
SELECT town_id
|
||||
FROM commands
|
||||
@@ -143,11 +212,17 @@ def _fetch_pending_builds_all_towns(c, player_id):
|
||||
GROUP BY town_id
|
||||
ORDER BY MIN(updated_at) ASC
|
||||
''', (player_id,)).fetchall()
|
||||
_log.warning(f"[poll] build towns found: {[r['town_id'] for r in town_rows]}, busy: {busy_towns}")
|
||||
|
||||
results = []
|
||||
now = datetime.utcnow().isoformat()
|
||||
for town_row in town_rows:
|
||||
town_id = town_row['town_id']
|
||||
|
||||
# Skip this town if a build is already executing for it
|
||||
if town_id in busy_towns:
|
||||
continue
|
||||
|
||||
row = c.execute('''
|
||||
SELECT * FROM commands
|
||||
WHERE status = 'pending' AND type = 'build'
|
||||
@@ -172,23 +247,36 @@ def _fetch_pending_builds_all_towns(c, player_id):
|
||||
@api.route('/api/commands/pending', methods=['GET'])
|
||||
def get_pending_command():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
_log.warning(f"[poll] player_id={player_id!r} world_id={world_id!r}")
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id provided'}), 400
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
build_cmds = _fetch_pending_builds_all_towns(c, player_id) # one per town
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id)
|
||||
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id)
|
||||
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id)
|
||||
research_cmd = _fetch_pending_of_type(c, 'research', player_id)
|
||||
# Free up stuck 'executing' commands (e.g. if the game page was refreshed mid-execution)
|
||||
two_minutes_ago = (datetime.utcnow() - timedelta(minutes=2)).isoformat()
|
||||
c.execute('''
|
||||
UPDATE commands
|
||||
SET status = 'pending', result_msg = 'Requeued (timeout)'
|
||||
WHERE status = 'executing' AND updated_at < ? AND player_id = ?
|
||||
''', (two_minutes_ago, player_id))
|
||||
|
||||
build_cmds = _fetch_pending_builds_all_towns(c, player_id, world_id) # one per town
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id, world_id)
|
||||
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id, world_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id, world_id)
|
||||
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id, world_id)
|
||||
research_cmd = _fetch_pending_of_type(c, 'research', player_id, world_id)
|
||||
sync_req = _check_and_reset_sync(c, player_id)
|
||||
|
||||
# Determine player_key for world-specific settings if world_id is provided
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
# Farm settings
|
||||
farm_row = c.execute(
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_id,)
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
farm_settings = {
|
||||
'enabled': bool(farm_row['enabled']) if farm_row else False,
|
||||
@@ -197,7 +285,7 @@ def get_pending_command():
|
||||
|
||||
# Bot settings (bootcamp + rural trade)
|
||||
bot_row = c.execute(
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (str(player_id),)
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
bot_settings = {
|
||||
'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False,
|
||||
@@ -207,7 +295,7 @@ def get_pending_command():
|
||||
}
|
||||
|
||||
# One-shot manual attack flag
|
||||
attack_now_key = f'bootcamp_attack_now_{player_id}'
|
||||
attack_now_key = f'bootcamp_attack_now_{player_key}'
|
||||
flag_row = c.execute('SELECT value FROM kv_store WHERE key = ?', (attack_now_key,)).fetchone()
|
||||
if flag_row and flag_row['value'] == '1':
|
||||
bot_settings['attack_now'] = True
|
||||
@@ -295,10 +383,13 @@ def command_result(cmd_id):
|
||||
|
||||
# When an explicit farm_loot command succeeds, record the timestamp
|
||||
if cmd and cmd['type'] == 'farm_loot' and status == 'done' and cmd['player_id']:
|
||||
town_id = str(cmd['town_id'])
|
||||
world_id = town_id.split('_')[1] if '_' in town_id else None
|
||||
lf_key = f'last_farmed_{cmd["player_id"]}_{world_id}' if world_id else f'last_farmed_{cmd["player_id"]}'
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (f'last_farmed_{cmd["player_id"]}', now, now))
|
||||
''', (lf_key, now, now))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -313,12 +404,14 @@ def command_result(cmd_id):
|
||||
@api.route('/api/captcha/alert', methods=['POST'])
|
||||
def captcha_alert():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '').strip()
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id provided'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
detected = bool(data.get('detected', False))
|
||||
kv_key = f'captcha_active_{player_id}'
|
||||
# Key is world-specific so captcha in world A doesn't affect world B
|
||||
kv_key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
@@ -332,6 +425,7 @@ def captcha_alert():
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/market_data
|
||||
# Tampermonkey uploads the market scan data.
|
||||
@@ -363,9 +457,12 @@ def upload_market_data():
|
||||
@api.route('/api/farm_status', methods=['POST', 'GET'])
|
||||
def farm_status():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id'}), 400
|
||||
kv_key = f'farm_status_{player_id}'
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
kv_key = f'farm_status_{player_key}'
|
||||
conn = get_db()
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -379,7 +476,7 @@ def farm_status():
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (f'last_farmed_{player_id}', now, now))
|
||||
''', (f'last_farmed_{player_key}', now, now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
@@ -401,16 +498,19 @@ def api_bot_logs():
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', ''))
|
||||
world_id = str(data.get('world_id', ''))
|
||||
feature = data.get('feature', '')
|
||||
message = data.get('message', '')
|
||||
|
||||
if not player_id or not feature or not message:
|
||||
return jsonify({'error': 'missing fields'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
|
||||
(player_id, feature, message)
|
||||
(player_key, feature, message)
|
||||
)
|
||||
# Keep only latest 50 per player/feature
|
||||
conn.execute('''
|
||||
@@ -421,7 +521,7 @@ def api_bot_logs():
|
||||
WHERE player_id = ? AND feature = ?
|
||||
ORDER BY id DESC LIMIT 50
|
||||
)
|
||||
''', (player_id, feature, player_id, feature))
|
||||
''', (player_key, feature, player_key, feature))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
646
routes/attack_planner.py
Normal file
646
routes/attack_planner.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
routes/attack_planner.py — Coordinated Attack Planner Blueprint
|
||||
|
||||
Endpoints:
|
||||
GET /api/server_time Clock sync
|
||||
POST /api/<world>/attack_plans Create plan
|
||||
GET /api/<world>/attack_plans List plans
|
||||
GET /api/<world>/attack_plans/<plan_id> Get plan details
|
||||
POST /api/<world>/attack_plans/<plan_id>/arm Arm plan (lock & activate)
|
||||
POST /api/<world>/attack_plans/<plan_id>/cancel Cancel plan
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants Add participant
|
||||
DELETE /api/<world>/attack_plans/<plan_id>/participants/<town_id> Remove participant
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/cancel Self-cancel
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/status Bot status report
|
||||
|
||||
Access control:
|
||||
- 'attack_planner_admin' feature: can create/arm/cancel plans, add participants
|
||||
- 'attack_planner' feature: can view plans, self-cancel, report status
|
||||
- Default new members: neither feature (off by default)
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from db import get_db
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
attack_planner = Blueprint('attack_planner', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/server_time
|
||||
# Used by Tampermonkey to measure clock offset (no auth required,
|
||||
# it's just a timestamp — no sensitive data).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/server_time', methods=['GET'])
|
||||
def server_time():
|
||||
return jsonify({'server_time_ms': int(time.time() * 1000)})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _get_clan_from_request():
|
||||
key = request.headers.get('X-Clan-Key', '').strip()
|
||||
if not key:
|
||||
return None
|
||||
conn = get_db()
|
||||
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
|
||||
conn.close()
|
||||
return clan
|
||||
|
||||
|
||||
def _get_member_features(clan_id, player_id):
|
||||
"""Return set of feature strings for this player in this clan."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT features FROM clan_members WHERE clan_id = ? AND player_id = ?',
|
||||
(clan_id, str(player_id))
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return set()
|
||||
return set(f.strip() for f in (row['features'] or '').split(',') if f.strip())
|
||||
|
||||
|
||||
def _has_feature(clan_id, player_id, feature):
|
||||
return feature in _get_member_features(clan_id, player_id)
|
||||
|
||||
|
||||
def _get_world_data(world_id):
|
||||
"""Fetch world_speed and unit_speeds from kv_store."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?",
|
||||
(f'world_data_{world_id}',)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row['value'])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_participant(origin_x, origin_y, origin_sea,
|
||||
target_x, target_y, target_sea,
|
||||
units, world_data):
|
||||
"""
|
||||
Calculate travel_time, send_time, return_time, attack_type,
|
||||
transport_needed, transport_count for one participant.
|
||||
|
||||
Returns dict with all calculated fields, or sets is_feasible=False with error_msg.
|
||||
"""
|
||||
result = {
|
||||
'attack_type': 'attack_land',
|
||||
'transport_needed': False,
|
||||
'transport_count': 0,
|
||||
'travel_time_secs': None,
|
||||
'is_feasible': True,
|
||||
'error_msg': None,
|
||||
}
|
||||
|
||||
if origin_x is None or origin_y is None or target_x is None or target_y is None:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Missing coordinates'
|
||||
return result
|
||||
|
||||
if not world_data:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'World speed data not available — wait for client sync'
|
||||
return result
|
||||
|
||||
world_speed = world_data.get('world_speed', 1.0)
|
||||
unit_speeds = world_data.get('unit_speeds', {})
|
||||
|
||||
# Distance (Pythagorean)
|
||||
distance = math.sqrt((target_x - origin_x) ** 2 + (target_y - origin_y) ** 2)
|
||||
|
||||
# Determine attack type: sea if different sea zone
|
||||
is_sea = (origin_sea is not None and target_sea is not None and origin_sea != target_sea)
|
||||
result['attack_type'] = 'attack_sea' if is_sea else 'attack_land'
|
||||
|
||||
# Find slowest unit speed
|
||||
min_speed = None
|
||||
total_land_pop = 0
|
||||
has_units = False
|
||||
|
||||
for unit_id, count in (units or {}).items():
|
||||
if not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
ud = unit_speeds.get(unit_id)
|
||||
if not ud:
|
||||
continue
|
||||
has_units = True
|
||||
speed = ud.get('speed', 1.0)
|
||||
if min_speed is None or speed < min_speed:
|
||||
min_speed = speed
|
||||
if not ud.get('is_naval', False):
|
||||
total_land_pop += count * ud.get('population', 1)
|
||||
|
||||
if not has_units or min_speed is None:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'No valid units selected'
|
||||
return result
|
||||
|
||||
# Travel time: hours = distance / (speed * world_speed)
|
||||
if min_speed * world_speed <= 0:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Invalid speed data'
|
||||
return result
|
||||
|
||||
travel_hours = distance / (min_speed * world_speed)
|
||||
travel_secs = int(travel_hours * 3600)
|
||||
result['travel_time_secs'] = travel_secs
|
||||
|
||||
# Transport calculation for sea attacks with land units
|
||||
if is_sea and total_land_pop > 0:
|
||||
result['transport_needed'] = True
|
||||
transport_cap = 0
|
||||
for uid, ud in unit_speeds.items():
|
||||
if ud.get('capacity', 0) > 0:
|
||||
transport_cap = ud['capacity']
|
||||
break
|
||||
if transport_cap <= 0:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Transport capacity data missing — wait for client sync'
|
||||
return result
|
||||
result['transport_count'] = math.ceil(total_land_pop / transport_cap)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans
|
||||
# Create a new plan (draft). Requires attack_planner_admin feature.
|
||||
# Body: { player_id, plan_name, target_town_name, target_x, target_y,
|
||||
# target_sea, target_arrival_time }
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans', methods=['POST'])
|
||||
def create_plan(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No attack_planner_admin permission'}), 403
|
||||
|
||||
plan_name = data.get('plan_name', '').strip() or 'Επίθεση'
|
||||
target_name = data.get('target_town_name', '').strip()
|
||||
target_x = data.get('target_x')
|
||||
target_y = data.get('target_y')
|
||||
arrival = data.get('target_arrival_time') # unix epoch int
|
||||
|
||||
if not arrival:
|
||||
return jsonify({'error': 'target_arrival_time required'}), 400
|
||||
|
||||
now = int(time.time())
|
||||
if int(arrival) <= now + 120:
|
||||
return jsonify({'error': 'Arrival time must be at least 2 minutes in the future'}), 400
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.execute('''
|
||||
INSERT INTO attack_plans
|
||||
(world_id, plan_name, created_by_player_id,
|
||||
target_town_name, target_x, target_y,
|
||||
target_arrival_time, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft')
|
||||
''', (world_id, plan_name, player_id, target_name, target_x, target_y, int(arrival)))
|
||||
plan_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'ok': True, 'plan_id': plan_id})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans
|
||||
# List all non-cancelled plans for this world.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans', methods=['GET'])
|
||||
@login_required
|
||||
def list_plans(world_id):
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT ap.*,
|
||||
COUNT(app.id) as participant_count
|
||||
FROM attack_plans ap
|
||||
LEFT JOIN attack_plan_participants app ON app.plan_id = ap.id
|
||||
WHERE ap.world_id = ? AND ap.status != 'cancelled'
|
||||
GROUP BY ap.id
|
||||
ORDER BY ap.target_arrival_time ASC
|
||||
''', (world_id,)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans/<plan_id>
|
||||
# Full plan details including participants.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_plan(world_id, plan_id):
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
'SELECT * FROM attack_plans WHERE id = ? AND world_id = ?',
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found'}), 404
|
||||
|
||||
participants = conn.execute(
|
||||
'SELECT * FROM attack_plan_participants WHERE plan_id = ? ORDER BY send_time ASC',
|
||||
(plan_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = dict(plan)
|
||||
result['participants'] = []
|
||||
for p in participants:
|
||||
row = dict(p)
|
||||
try:
|
||||
row['units'] = json.loads(row['units'])
|
||||
except Exception:
|
||||
row['units'] = {}
|
||||
result['participants'].append(row)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants
|
||||
# Add a participant town to the plan.
|
||||
# Body: { player_id, origin_town_id, origin_town_name, origin_x, origin_y,
|
||||
# origin_sea, units: {unit_id: count, ...} }
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/participants', methods=['POST'])
|
||||
def add_participant(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
requester_id = str(data.get('requester_player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No attack_planner_admin permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
"SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'",
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found or not in draft status'}), 404
|
||||
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
origin_town_id = str(data.get('origin_town_id', '')).strip()
|
||||
origin_town_name = data.get('origin_town_name', '')
|
||||
origin_x = data.get('origin_x')
|
||||
origin_y = data.get('origin_y')
|
||||
origin_sea = data.get('origin_sea')
|
||||
units = data.get('units', {})
|
||||
|
||||
if not player_id or not origin_town_id:
|
||||
conn.close()
|
||||
return jsonify({'error': 'player_id and origin_town_id required'}), 400
|
||||
|
||||
# Get target sea from plan (we store it? No — we need to infer from coordinates)
|
||||
# Approximate: sea = floor(x/100)*10 + floor(y/100)
|
||||
target_x = plan['target_x']
|
||||
target_y = plan['target_y']
|
||||
target_sea = None
|
||||
if target_x is not None and target_y is not None:
|
||||
target_sea = int(math.floor(target_x / 100)) * 10 + int(math.floor(target_y / 100))
|
||||
|
||||
world_data = _get_world_data(world_id)
|
||||
calc = _calculate_participant(
|
||||
origin_x, origin_y, origin_sea,
|
||||
target_x, target_y, target_sea,
|
||||
units, world_data
|
||||
)
|
||||
|
||||
arrival = plan['target_arrival_time']
|
||||
send_time = None
|
||||
return_time = None
|
||||
if calc['travel_time_secs'] is not None:
|
||||
send_time = arrival - calc['travel_time_secs']
|
||||
return_time = arrival + calc['travel_time_secs']
|
||||
|
||||
# Validate: send_time must be at least 2 min in future
|
||||
now = int(time.time())
|
||||
if send_time <= now + 120:
|
||||
calc['is_feasible'] = False
|
||||
calc['error_msg'] = (
|
||||
f"Send time is too soon ({(send_time - now)//60}m remaining). "
|
||||
"Choose a later arrival time or remove this town."
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute('''
|
||||
INSERT INTO attack_plan_participants
|
||||
(plan_id, player_id, world_id, origin_town_id, origin_town_name,
|
||||
units, attack_type, transport_needed, transport_count,
|
||||
travel_time_secs, send_time, return_time, is_feasible, error_msg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, origin_town_id) DO UPDATE SET
|
||||
player_id = excluded.player_id,
|
||||
units = excluded.units,
|
||||
attack_type = excluded.attack_type,
|
||||
transport_needed= excluded.transport_needed,
|
||||
transport_count = excluded.transport_count,
|
||||
travel_time_secs= excluded.travel_time_secs,
|
||||
send_time = excluded.send_time,
|
||||
return_time = excluded.return_time,
|
||||
is_feasible = excluded.is_feasible,
|
||||
error_msg = excluded.error_msg,
|
||||
updated_at = datetime('now')
|
||||
''', (
|
||||
plan_id, player_id, world_id,
|
||||
origin_town_id, origin_town_name,
|
||||
json.dumps(units),
|
||||
calc['attack_type'],
|
||||
1 if calc['transport_needed'] else 0,
|
||||
calc['transport_count'],
|
||||
calc['travel_time_secs'],
|
||||
send_time, return_time,
|
||||
1 if calc['is_feasible'] else 0,
|
||||
calc['error_msg']
|
||||
))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'is_feasible': calc['is_feasible'],
|
||||
'error_msg': calc['error_msg'],
|
||||
'attack_type': calc['attack_type'],
|
||||
'transport_count': calc['transport_count'],
|
||||
'travel_time_secs': calc['travel_time_secs'],
|
||||
'send_time': send_time,
|
||||
'return_time': return_time,
|
||||
})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>
|
||||
# Remove a participant (coordinator/admin only, plan must be draft).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>',
|
||||
methods=['DELETE']
|
||||
)
|
||||
def remove_participant(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
requester_id = str(data.get('requester_player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
'DELETE FROM attack_plan_participants WHERE plan_id = ? AND origin_town_id = ?',
|
||||
(plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>/cancel
|
||||
# Player self-cancels their own town's participation (if > 2min before send).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/cancel',
|
||||
methods=['POST']
|
||||
)
|
||||
def cancel_participant(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'''SELECT app.*, ap.status as plan_status
|
||||
FROM attack_plan_participants app
|
||||
JOIN attack_plans ap ON ap.id = app.plan_id
|
||||
WHERE app.plan_id = ? AND app.origin_town_id = ? AND app.player_id = ?''',
|
||||
(plan_id, town_id, player_id)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Participant not found'}), 404
|
||||
|
||||
now = int(time.time())
|
||||
send_time = row['send_time'] or 0
|
||||
if send_time > 0 and (send_time - now) < 120:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Too close to send time — cannot cancel'}), 400
|
||||
|
||||
conn.execute(
|
||||
"UPDATE attack_plan_participants SET status='cancelled', updated_at=datetime('now') "
|
||||
"WHERE plan_id = ? AND origin_town_id = ?",
|
||||
(plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>/status
|
||||
# Tampermonkey bot reports its status (armed, sent, missed).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/status',
|
||||
methods=['POST']
|
||||
)
|
||||
def report_participant_status(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
new_status = data.get('status', '').strip()
|
||||
if new_status not in ('armed', 'sent', 'missed'):
|
||||
return jsonify({'error': 'Invalid status'}), 400
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE attack_plan_participants SET status=?, updated_at=datetime('now') "
|
||||
"WHERE plan_id = ? AND origin_town_id = ?",
|
||||
(new_status, plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# If all active participants are sent/missed, mark plan completed
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND status NOT IN ('sent','missed','cancelled')",
|
||||
(plan_id,)
|
||||
).fetchone()['n']
|
||||
if pending == 0:
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='completed', updated_at=datetime('now') WHERE id=?",
|
||||
(plan_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/arm
|
||||
# Lock the plan and activate it. Validates all participants feasible.
|
||||
# Requires attack_planner_admin.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/arm', methods=['POST'])
|
||||
def arm_plan(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
"SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'",
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found or not in draft'}), 404
|
||||
|
||||
# Check all participants are feasible and have valid send_times
|
||||
infeasible = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND is_feasible = 0 AND status != 'cancelled'",
|
||||
(plan_id,)
|
||||
).fetchone()['n']
|
||||
if infeasible > 0:
|
||||
conn.close()
|
||||
return jsonify({'error': f'{infeasible} participant(s) are not feasible — fix or remove them first'}), 400
|
||||
|
||||
now = int(time.time())
|
||||
# Check earliest send_time is >= 2 min away
|
||||
earliest = conn.execute(
|
||||
"SELECT MIN(send_time) as earliest FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND status != 'cancelled'",
|
||||
(plan_id,)
|
||||
).fetchone()['earliest']
|
||||
if earliest and (earliest - now) < 120:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Earliest send time is less than 2 minutes away'}), 400
|
||||
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='active', updated_at=datetime('now') WHERE id=?",
|
||||
(plan_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/cancel
|
||||
# Cancel the entire plan.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/cancel', methods=['POST'])
|
||||
def cancel_plan(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='cancelled', updated_at=datetime('now') "
|
||||
"WHERE id = ? AND world_id = ?",
|
||||
(plan_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans/active
|
||||
# Tampermonkey polls this to get active plans for its towns.
|
||||
# Returns plans where this player has active participants.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/active', methods=['GET'])
|
||||
def get_active_plans(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
player_id = request.args.get('player_id', '').strip()
|
||||
if not player_id:
|
||||
return jsonify({'error': 'player_id required'}), 400
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT ap.id as plan_id, ap.plan_name,
|
||||
ap.target_town_name, ap.target_x, ap.target_y,
|
||||
ap.target_arrival_time, ap.status as plan_status,
|
||||
app.id as participant_id,
|
||||
app.origin_town_id, app.origin_town_name,
|
||||
app.units, app.attack_type,
|
||||
app.transport_count,
|
||||
app.send_time, app.return_time,
|
||||
app.status as participant_status
|
||||
FROM attack_plans ap
|
||||
JOIN attack_plan_participants app ON app.plan_id = ap.id
|
||||
WHERE ap.world_id = ?
|
||||
AND ap.status = 'active'
|
||||
AND app.player_id = ?
|
||||
AND app.status IN ('pending', 'armed')
|
||||
ORDER BY app.send_time ASC
|
||||
''', (world_id, player_id)).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
try:
|
||||
row['units'] = json.loads(row['units'])
|
||||
except Exception:
|
||||
row['units'] = {}
|
||||
result.append(row)
|
||||
|
||||
return jsonify(result)
|
||||
@@ -126,13 +126,14 @@ def options():
|
||||
members = []
|
||||
if clan:
|
||||
rows = conn.execute(
|
||||
'''SELECT cm.id, cm.player_id, cm.player_name, cm.joined_at, cm.features,
|
||||
ts.updated_at
|
||||
'''SELECT cm.id, cm.player_id, cm.player_name, cm.world_id, cm.joined_at, cm.features,
|
||||
MAX(ts.updated_at) as updated_at
|
||||
FROM clan_members cm
|
||||
LEFT JOIN town_state ts ON ts.player_id = cm.player_id
|
||||
AND ts.world_id = cm.world_id
|
||||
WHERE cm.clan_id = ?
|
||||
GROUP BY cm.player_id
|
||||
ORDER BY cm.joined_at DESC''',
|
||||
GROUP BY cm.player_id, cm.world_id
|
||||
ORDER BY cm.player_name ASC, cm.world_id ASC''',
|
||||
(clan['id'],)
|
||||
).fetchall()
|
||||
|
||||
@@ -150,10 +151,13 @@ def options():
|
||||
'id': row['id'],
|
||||
'player_id': row['player_id'],
|
||||
'player_name': row['player_name'] or 'Άγνωστος',
|
||||
'world_id': row['world_id'] or '–',
|
||||
'joined_at': row['joined_at'][:10] if row['joined_at'] else '',
|
||||
'is_online': is_online,
|
||||
'feat_farm': 'farm' in (row['features'] or 'farm,admin'),
|
||||
'feat_admin': 'admin' in (row['features'] or 'farm,admin'),
|
||||
'feat_atk_planner': 'attack_planner' in (row['features'] or ''),
|
||||
'feat_atk_planner_admin': 'attack_planner_admin' in (row['features'] or ''),
|
||||
})
|
||||
|
||||
conn.close()
|
||||
@@ -209,17 +213,17 @@ def regenerate_key():
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
@auth.route('/auth/clan/remove-member/<player_id>', methods=['POST'])
|
||||
@auth.route('/auth/clan/remove-member/<player_id>/<path:world_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_member(player_id):
|
||||
def remove_member(player_id, world_id):
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
if clan:
|
||||
conn.execute(
|
||||
'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ?',
|
||||
(clan['id'], player_id)
|
||||
'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ? AND world_id = ?',
|
||||
(clan['id'], player_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -229,12 +233,15 @@ def remove_member(player_id):
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/update-features/<player_id>
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/update-features/<player_id>', methods=['POST'])
|
||||
@auth.route('/auth/clan/update-features/<player_id>/<path:world_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_member_features(player_id):
|
||||
def update_member_features(player_id, world_id):
|
||||
farm = 'farm' if request.form.get('farm') else None
|
||||
admin = 'admin' if request.form.get('admin') else None
|
||||
features = ','.join(f for f in [farm, admin] if f) or ''
|
||||
atk_planner = 'attack_planner' if request.form.get('attack_planner') else None
|
||||
atk_planner_admin = 'attack_planner_admin' if request.form.get('attack_planner_admin') else None
|
||||
|
||||
features = ','.join(f for f in [farm, admin, atk_planner, atk_planner_admin] if f) or ''
|
||||
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
@@ -242,14 +249,15 @@ def update_member_features(player_id):
|
||||
).fetchone()
|
||||
if clan:
|
||||
conn.execute(
|
||||
'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ?',
|
||||
(features, clan['id'], player_id)
|
||||
'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ? AND world_id = ?',
|
||||
(features, clan['id'], player_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/add-admin
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -26,16 +26,26 @@ def index():
|
||||
|
||||
# Only fetch players that are members of this clan
|
||||
rows = conn.execute('''
|
||||
SELECT ts.player, ts.player_id, MAX(ts.updated_at) as last_seen, MAX(ts.world_id) as world_id
|
||||
SELECT ts.player, ts.player_id, ts.world_id, MAX(ts.updated_at) as last_seen
|
||||
FROM town_state ts
|
||||
INNER JOIN clan_members cm ON cm.player_id = ts.player_id AND cm.clan_id = ?
|
||||
WHERE ts.player IS NOT NULL
|
||||
GROUP BY ts.player, ts.player_id
|
||||
ORDER BY ts.player ASC
|
||||
GROUP BY ts.player, ts.player_id, ts.world_id
|
||||
ORDER BY ts.player ASC, ts.world_id ASC
|
||||
''', (clan_id,)).fetchall()
|
||||
|
||||
captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").fetchall()
|
||||
active_captchas = {r['key'].replace('captcha_active_', ''): True for r in captcha_rows if r['value'] == '1'}
|
||||
# Key format is captcha_active_{player_id}_{world_id} — build a (player_id, world_id) → bool map
|
||||
active_captchas = {}
|
||||
for r in captcha_rows:
|
||||
if r['value'] != '1':
|
||||
continue
|
||||
parts = r['key'].replace('captcha_active_', '').split('_', 1)
|
||||
if len(parts) == 2:
|
||||
active_captchas[(parts[0], parts[1])] = True
|
||||
else:
|
||||
# Legacy key (player_id only) — keep working
|
||||
active_captchas[(parts[0], '')] = True
|
||||
conn.close()
|
||||
|
||||
players = []
|
||||
@@ -50,31 +60,59 @@ def index():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
wid = r['world_id'] or ''
|
||||
captcha_active = (
|
||||
active_captchas.get((r['player_id'], wid), False) or
|
||||
active_captchas.get((r['player_id'], ''), False) # legacy fallback
|
||||
)
|
||||
players.append({
|
||||
'player': r['player'],
|
||||
'player_id': r['player_id'],
|
||||
'world_id': r['world_id'] or 'Unknown',
|
||||
'world_id': wid or 'Unknown',
|
||||
'is_online': is_online,
|
||||
'captcha_active': active_captchas.get(r['player_id'], False)
|
||||
'captcha_active': captcha_active
|
||||
})
|
||||
|
||||
|
||||
return render_template('index.html', players=players, no_clan=False)
|
||||
|
||||
|
||||
@dashboard.route('/player/<player_id>')
|
||||
@dashboard.route('/player/<player_id>/<world_id>')
|
||||
@login_required
|
||||
def player_hub(player_id):
|
||||
return render_template('hub.html', player_id=player_id)
|
||||
def player_hub(player_id, world_id):
|
||||
return render_template('hub.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/admin')
|
||||
@dashboard.route('/player/<player_id>/<world_id>/admin')
|
||||
@login_required
|
||||
def player_dashboard(player_id):
|
||||
return render_template('dashboard.html', player_id=player_id)
|
||||
def player_dashboard(player_id, world_id):
|
||||
return render_template('dashboard.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/farm')
|
||||
@dashboard.route('/player/<player_id>/<world_id>/farm')
|
||||
@login_required
|
||||
def player_farm(player_id):
|
||||
return render_template('farm.html', player_id=player_id)
|
||||
def player_farm(player_id, world_id):
|
||||
return render_template('farm.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/tracker')
|
||||
@login_required
|
||||
def player_tracker(player_id, world_id):
|
||||
return render_template('tracker.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/attack-planner')
|
||||
@login_required
|
||||
def player_attack_planner(player_id, world_id):
|
||||
clan_key = ''
|
||||
if current_user.clan_id:
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT clan_key FROM clans WHERE id = ?', (current_user.clan_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if clan:
|
||||
clan_key = clan['clan_key']
|
||||
return render_template('attack_planner.html',
|
||||
player_id=player_id,
|
||||
world_id=world_id,
|
||||
clan_key=clan_key)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -84,9 +122,12 @@ def player_farm(player_id):
|
||||
@dashboard.route('/dashboard/farm-settings', methods=['GET'])
|
||||
def get_farm_settings():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_id,)
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
@@ -99,6 +140,9 @@ def set_farm_settings():
|
||||
if not data or 'player_id' not in data:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
player_id = data['player_id']
|
||||
world_id = data.get('world_id', '')
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
enabled = 1 if data.get('enabled') else 0
|
||||
loot_option = int(data.get('loot_option', 1))
|
||||
conn = get_db()
|
||||
@@ -109,7 +153,7 @@ def set_farm_settings():
|
||||
enabled = excluded.enabled,
|
||||
loot_option = excluded.loot_option,
|
||||
updated_at = excluded.updated_at
|
||||
''', (player_id, enabled, loot_option, datetime.utcnow().isoformat()))
|
||||
''', (player_key, enabled, loot_option, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
@@ -122,14 +166,22 @@ def set_farm_settings():
|
||||
@dashboard.route('/dashboard/farm-data', methods=['GET'])
|
||||
def get_farm_data():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
if world_id:
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ? AND world_id = ?',
|
||||
(player_id, world_id)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ?', (player_id,)
|
||||
).fetchall()
|
||||
|
||||
# Also fetch when the bot last farmed
|
||||
# Also fetch when the bot last farmed (per world)
|
||||
lf_key = f'last_farmed_{player_id}_{world_id}' if world_id else f'last_farmed_{player_id}'
|
||||
lf_row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (f'last_farmed_{player_id}',)
|
||||
"SELECT value FROM kv_store WHERE key = ?", (lf_key,)
|
||||
).fetchone()
|
||||
last_farmed_at = lf_row['value'] if lf_row else None
|
||||
conn.close()
|
||||
@@ -176,14 +228,26 @@ def get_market_data():
|
||||
@dashboard.route('/dashboard/towns', methods=['GET'])
|
||||
def get_towns():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT town_id, town_name, player, player_id, alliance_id,
|
||||
world_id, x, y, sea, data, updated_at
|
||||
FROM town_state
|
||||
WHERE player_id = ?
|
||||
ORDER BY town_name ASC
|
||||
''', (player_id, )).fetchall()
|
||||
|
||||
query = '''
|
||||
SELECT ts.town_id, ts.town_name, ts.player, ts.player_id, ts.alliance_id,
|
||||
ts.world_id, ts.x, ts.y, ts.sea, ts.data, ts.updated_at,
|
||||
tb.blueprint_name, tb.is_active as blueprint_active
|
||||
FROM town_state ts
|
||||
LEFT JOIN town_blueprints tb ON ts.town_id = tb.town_id AND tb.is_active = 1
|
||||
WHERE ts.player_id = ?
|
||||
'''
|
||||
params = [player_id]
|
||||
|
||||
if world_id:
|
||||
query += ' AND ts.world_id = ?'
|
||||
params.append(world_id)
|
||||
|
||||
query += ' ORDER BY ts.town_name ASC'
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
towns = []
|
||||
@@ -220,11 +284,95 @@ def get_towns():
|
||||
'bonuses': d.get('bonuses', {}),
|
||||
'wonder_points': d.get('wonder_points', 0),
|
||||
'total_points': d.get('total_points', 0),
|
||||
'alliance_name': d.get('alliance_name', None)
|
||||
'alliance_name': d.get('alliance_name', None),
|
||||
'blueprint_name': row['blueprint_name'],
|
||||
'blueprint_active': bool(row['blueprint_active'])
|
||||
})
|
||||
return jsonify(towns)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/clan-towns?world_id=...
|
||||
# Returns all towns for ALL clan members in a given world.
|
||||
# Used by attack planner to let admin pick any player's town.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/clan-towns', methods=['GET'])
|
||||
@login_required
|
||||
def get_clan_towns():
|
||||
world_id = request.args.get('world_id', '').strip()
|
||||
clan_id = current_user.clan_id
|
||||
if not clan_id or not world_id:
|
||||
return jsonify([])
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT ts.town_id, ts.town_name, ts.player, ts.player_id,
|
||||
ts.x, ts.y, ts.sea, ts.world_id, ts.updated_at, ts.data
|
||||
FROM town_state ts
|
||||
INNER JOIN clan_members cm
|
||||
ON cm.player_id = ts.player_id
|
||||
AND cm.world_id = ts.world_id
|
||||
AND cm.clan_id = ?
|
||||
WHERE ts.world_id = ?
|
||||
ORDER BY ts.player ASC, ts.town_name ASC
|
||||
''', (clan_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
|
||||
towns = []
|
||||
for r in rows:
|
||||
d = json.loads(r['data']) if r['data'] else {}
|
||||
towns.append({
|
||||
'town_id': r['town_id'],
|
||||
'town_name': r['town_name'],
|
||||
'player': r['player'],
|
||||
'player_id': r['player_id'],
|
||||
'world_id': r['world_id'],
|
||||
'x': r['x'],
|
||||
'y': r['y'],
|
||||
'sea': r['sea'],
|
||||
'units': d.get('units', {}),
|
||||
})
|
||||
return jsonify(towns)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/blueprints
|
||||
# Toggle a blueprint for a specific town
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/blueprints', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_blueprint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
town_id = data.get('town_id')
|
||||
blueprint_name = data.get('blueprint_name', 'Standard Growth')
|
||||
|
||||
if not town_id:
|
||||
return jsonify({'error': 'missing town_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
|
||||
# Check if currently active
|
||||
row = conn.execute('SELECT is_active FROM town_blueprints WHERE town_id = ?', (town_id,)).fetchone()
|
||||
|
||||
new_state = 1
|
||||
if row and row['is_active'] == 1:
|
||||
new_state = 0 # Toggle off
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO town_blueprints (town_id, blueprint_name, is_active, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(town_id) DO UPDATE SET
|
||||
blueprint_name = excluded.blueprint_name,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = excluded.updated_at
|
||||
''', (town_id, blueprint_name, new_state, datetime.utcnow().isoformat()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'ok': True, 'is_active': bool(new_state), 'blueprint_name': blueprint_name})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/client-status
|
||||
# Returns whether the Tampermonkey client is considered online.
|
||||
@@ -259,16 +407,26 @@ def client_status():
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
||||
def captcha_status():
|
||||
player_id = request.args.get('player_id')
|
||||
player_id = request.args.get('player_id', '').strip()
|
||||
world_id = request.args.get('world_id', '').strip()
|
||||
conn = get_db()
|
||||
# Try world-specific key first, fall back to legacy player-only key
|
||||
key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', )
|
||||
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if not row and world_id:
|
||||
# Legacy fallback
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?",
|
||||
(f'captcha_active_{player_id}',)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
active = bool(row and row['value'] == '1')
|
||||
return jsonify({'captcha_active': active})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/commands/queue
|
||||
# Returns pending+executing BUILD commands for a specific town,
|
||||
@@ -325,13 +483,24 @@ def reorder_commands():
|
||||
def get_commands():
|
||||
player_id = request.args.get('player_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT id, town_id, town_name, type, payload, status, result_msg, created_at, updated_at
|
||||
FROM commands
|
||||
WHERE player_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
''', (player_id, )).fetchall()
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
|
||||
query = '''
|
||||
SELECT c.id, c.town_id, c.town_name, c.type, c.payload, c.status, c.result_msg, c.created_at, c.updated_at
|
||||
FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.player_id = ?
|
||||
'''
|
||||
params = [player_id]
|
||||
|
||||
if world_id:
|
||||
query += ' AND ts.world_id = ?'
|
||||
params.append(world_id)
|
||||
|
||||
query += ' ORDER BY c.id DESC LIMIT 50'
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
return jsonify([dict(r) for r in rows])
|
||||
@@ -450,15 +619,18 @@ def fail_stale_commands():
|
||||
@dashboard.route('/dashboard/bot-settings', methods=['GET', 'POST'])
|
||||
def bot_settings():
|
||||
player_id = request.args.get('player_id') or (request.json or {}).get('player_id')
|
||||
world_id = request.args.get('world_id') or (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'GET':
|
||||
row = c.execute(
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (player_id,)
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
@@ -484,7 +656,7 @@ def bot_settings():
|
||||
rural_trade_ratio = excluded.rural_trade_ratio,
|
||||
updated_at = excluded.updated_at
|
||||
''', (
|
||||
player_id,
|
||||
player_key,
|
||||
int(bool(data.get('bootcamp_enabled', 0))),
|
||||
int(bool(data.get('bootcamp_use_def', 0))),
|
||||
int(bool(data.get('rural_trade_enabled', 0))),
|
||||
@@ -503,16 +675,19 @@ def bot_settings():
|
||||
@dashboard.route('/dashboard/bot-logs', methods=['GET', 'POST'])
|
||||
def bot_logs():
|
||||
player_id = request.args.get('player_id') or (request.json or {}).get('player_id')
|
||||
world_id = request.args.get('world_id') or (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'GET':
|
||||
feature = request.args.get('feature', '')
|
||||
query = 'SELECT * FROM bot_logs WHERE player_id = ?'
|
||||
params = [player_id]
|
||||
params = [player_key]
|
||||
if feature:
|
||||
query += ' AND feature = ?'
|
||||
params.append(feature)
|
||||
@@ -527,7 +702,7 @@ def bot_logs():
|
||||
message = data.get('message', '')
|
||||
c.execute(
|
||||
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
|
||||
(player_id, feature, message)
|
||||
(player_key, feature, message)
|
||||
)
|
||||
# Prune: keep only the latest 50 per player/feature
|
||||
c.execute('''
|
||||
@@ -538,7 +713,7 @@ def bot_logs():
|
||||
WHERE player_id = ? AND feature = ?
|
||||
ORDER BY id DESC LIMIT 50
|
||||
)
|
||||
''', (player_id, feature, player_id, feature))
|
||||
''', (player_key, feature, player_key, feature))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
@@ -552,9 +727,12 @@ def bot_logs():
|
||||
@dashboard.route('/dashboard/bootcamp-attack-now', methods=['POST'])
|
||||
def bootcamp_attack_now():
|
||||
player_id = (request.json or {}).get('player_id')
|
||||
world_id = (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
key = f'bootcamp_attack_now_{player_id}'
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
key = f'bootcamp_attack_now_{player_key}'
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, '1', ?)
|
||||
|
||||
214
routes/tracker.py
Normal file
214
routes/tracker.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
routes/tracker.py — Live Tracker Blueprint
|
||||
Handles:
|
||||
POST /api/<world_id>/movements (Tampermonkey pushes movement data)
|
||||
GET /api/<world_id>/movements/<player_id> (Dashboard initial load)
|
||||
GET /api/<world_id>/movements/<player_id>/stream (SSE push stream)
|
||||
|
||||
All data is isolated per player_id + world_id.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||
from db import get_db
|
||||
from datetime import datetime
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
tracker = Blueprint('tracker', __name__)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# In-memory SSE subscriber registry
|
||||
# Key: "<world_id>:<player_id>" Value: list of queue.Queue()
|
||||
# One Queue per open dashboard tab. Thread-safe via a lock.
|
||||
# ----------------------------------------------------------------
|
||||
_subscribers = {}
|
||||
_sub_lock = threading.Lock()
|
||||
|
||||
|
||||
def _sub_key(player_id, world_id):
|
||||
return f"{world_id}:{player_id}"
|
||||
|
||||
|
||||
def _notify(player_id, world_id, payload):
|
||||
"""Push a JSON payload to all SSE subscribers for this player+world."""
|
||||
key = _sub_key(player_id, world_id)
|
||||
data = json.dumps(payload)
|
||||
with _sub_lock:
|
||||
for q in _subscribers.get(key, []):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass # slow consumer — drop silently
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Helper: read clan from X-Clan-Key header (same as api.py)
|
||||
# ----------------------------------------------------------------
|
||||
def _get_clan_from_request():
|
||||
key = request.headers.get('X-Clan-Key', '').strip()
|
||||
if not key:
|
||||
return None
|
||||
conn = get_db()
|
||||
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
|
||||
conn.close()
|
||||
return clan
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# POST /api/<world_id>/movements
|
||||
# Tampermonkey sends the current movement snapshot.
|
||||
# Requires X-Clan-Key header (same as all other bot endpoints).
|
||||
# Body: { player_id, world_id, movements: [{...}, ...] }
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements', methods=['POST'])
|
||||
def receive_movements(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'error': 'no data'}), 400
|
||||
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
movements = data.get('movements', [])
|
||||
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
# Normalise world_id — trust the URL param, not the body
|
||||
world_id = world_id.strip()
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
# 1. Upsert each movement (UNIQUE on player_id+world_id+command_id)
|
||||
for m in movements:
|
||||
cmd_id = str(m.get('id', '')).strip()
|
||||
if not cmd_id:
|
||||
continue
|
||||
c.execute('''
|
||||
INSERT INTO movements
|
||||
(player_id, world_id, command_id, cmd_type,
|
||||
origin_town, origin_player, target_town, target_player,
|
||||
arrival_at, raw_data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id, world_id, command_id) DO UPDATE SET
|
||||
cmd_type = excluded.cmd_type,
|
||||
origin_town = excluded.origin_town,
|
||||
origin_player = excluded.origin_player,
|
||||
target_town = excluded.target_town,
|
||||
target_player = excluded.target_player,
|
||||
arrival_at = excluded.arrival_at,
|
||||
raw_data = excluded.raw_data,
|
||||
updated_at = excluded.updated_at
|
||||
''', (
|
||||
player_id, world_id, cmd_id,
|
||||
m.get('type', 'unknown'),
|
||||
m.get('origin_town'), m.get('origin_player'),
|
||||
m.get('target_town'), m.get('target_player'),
|
||||
m.get('arrival_at'),
|
||||
json.dumps(m),
|
||||
now
|
||||
))
|
||||
|
||||
# 2. Purge stale entries: movements that are no longer in the snapshot
|
||||
# (the game already resolved them). We use command_ids sent in this batch.
|
||||
live_ids = [str(m.get('id', '')) for m in movements if m.get('id')]
|
||||
if live_ids:
|
||||
placeholders = ','.join('?' * len(live_ids))
|
||||
c.execute(f'''
|
||||
DELETE FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
AND command_id NOT IN ({placeholders})
|
||||
''', [player_id, world_id] + live_ids)
|
||||
else:
|
||||
# Empty snapshot → all movements resolved, clear the table for this player+world
|
||||
c.execute(
|
||||
'DELETE FROM movements WHERE player_id = ? AND world_id = ?',
|
||||
(player_id, world_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 3. Read back the current state and notify SSE subscribers immediately
|
||||
rows = c.execute('''
|
||||
SELECT command_id, cmd_type, origin_town, origin_player,
|
||||
target_town, target_player, arrival_at
|
||||
FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY arrival_at ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = [dict(r) for r in rows]
|
||||
_notify(player_id, world_id, {'movements': result})
|
||||
|
||||
return jsonify({'ok': True, 'stored': len(result)})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# GET /api/<world_id>/movements/<player_id>
|
||||
# Dashboard initial load — returns current snapshot from DB.
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements/<player_id>', methods=['GET'])
|
||||
def get_movements(world_id, player_id):
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT command_id, cmd_type, origin_town, origin_player,
|
||||
target_town, target_player, arrival_at
|
||||
FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY arrival_at ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
return jsonify({'movements': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# GET /api/<world_id>/movements/<player_id>/stream
|
||||
# SSE endpoint — keeps connection open, pushes updates to dashboard.
|
||||
# Each connected dashboard tab gets its own Queue.
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements/<player_id>/stream', methods=['GET'])
|
||||
def stream_movements(world_id, player_id):
|
||||
key = _sub_key(player_id, world_id)
|
||||
q = queue.Queue(maxsize=20)
|
||||
|
||||
with _sub_lock:
|
||||
_subscribers.setdefault(key, []).append(q)
|
||||
|
||||
def generate():
|
||||
# Send a comment immediately so the browser confirms the connection
|
||||
yield ': connected\n\n'
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = q.get(timeout=30)
|
||||
yield f'data: {data}\n\n'
|
||||
except queue.Empty:
|
||||
# Heartbeat — keeps the connection alive through proxies/firewalls
|
||||
yield ': heartbeat\n\n'
|
||||
except GeneratorExit:
|
||||
pass
|
||||
finally:
|
||||
with _sub_lock:
|
||||
subs = _subscribers.get(key, [])
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
if not subs:
|
||||
_subscribers.pop(key, None)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no', # disables nginx buffering (important!)
|
||||
}
|
||||
)
|
||||
@@ -163,6 +163,10 @@ select:focus, input:focus { outline: none; border-color: #c8a44a; }
|
||||
.btn-danger { background: #8b2222; color: #fff; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.75rem; }
|
||||
|
||||
.seg-btn:hover { background: rgba(200, 164, 74, 0.1) !important; color: #c8a44a !important; }
|
||||
.seg-btn.active { background: #c8a44a !important; color: #1a1a2e !important; font-weight: bold; box-shadow: 0 2px 5px rgba(0,0,0,0.3); }
|
||||
|
||||
|
||||
#no-town-selected {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
@@ -314,9 +318,9 @@ tr:hover td { background: #1e1e40; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Building & Academy Picker Modal
|
||||
Building, Academy, Unit, Market & Blueprint Modals
|
||||
========================================================================== */
|
||||
#building-modal-overlay, #academy-modal-overlay {
|
||||
#building-modal-overlay, #academy-modal-overlay, #unit-modal-overlay, #market-modal-overlay, #blueprints-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -325,9 +329,9 @@ tr:hover td { background: #1e1e40; }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#building-modal-overlay.open, #academy-modal-overlay.open { display: flex; }
|
||||
#building-modal-overlay.open, #academy-modal-overlay.open, #unit-modal-overlay.open, #market-modal-overlay.open, #blueprints-modal-overlay.open { display: flex; }
|
||||
|
||||
#building-modal, #academy-modal {
|
||||
#building-modal, #academy-modal, #unit-modal, #market-modal, #blueprints-modal {
|
||||
background: #16213e;
|
||||
border: 2px solid #c8a44a;
|
||||
border-radius: 10px;
|
||||
@@ -342,7 +346,7 @@ tr:hover td { background: #1e1e40; }
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
#building-modal-header, #academy-modal-header {
|
||||
#building-modal-header, #academy-modal-header, .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -350,12 +354,12 @@ tr:hover td { background: #1e1e40; }
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #2a4a6a;
|
||||
}
|
||||
#building-modal-header h3, #academy-modal-header h3 {
|
||||
#building-modal-header h3, #academy-modal-header h3, .modal-header h3 {
|
||||
color: #c8a44a;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
#building-modal-close, #academy-modal-close {
|
||||
#building-modal-close, #academy-modal-close, #unit-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
@@ -364,7 +368,7 @@ tr:hover td { background: #1e1e40; }
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
#building-modal-close:hover, #academy-modal-close:hover { color: #fff; }
|
||||
#building-modal-close:hover, #academy-modal-close:hover, #unit-modal-close:hover { color: #fff; }
|
||||
|
||||
#building-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
window.fetchTowns = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/towns?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(`/dashboard/towns?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`);
|
||||
window.towns = await res.json();
|
||||
window.renderTowns();
|
||||
window.updateServerStatus(true);
|
||||
@@ -52,7 +52,7 @@ window.fetchClientStatus = async function() {
|
||||
|
||||
window.fetchLog = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/commands?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(`/dashboard/commands?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`);
|
||||
const cmds = await res.json();
|
||||
window.cmds = cmds; // Save globally so viewer can see reserved resources
|
||||
if (window._logPanelMode === 'log') {
|
||||
@@ -97,8 +97,8 @@ window.sendCommand = async function() {
|
||||
const town = window.getSelectedTown();
|
||||
if (!town) return alert('Select a town first.');
|
||||
|
||||
const type = document.getElementById('cmd-type').value;
|
||||
if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Command Type) πρώτα.');
|
||||
const type = window.currentCmdType;
|
||||
if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Κατασκευή/Στρατός/Παζάρι/Έρευνα) πρώτα.');
|
||||
|
||||
let payload = {};
|
||||
|
||||
@@ -132,7 +132,7 @@ window.sendCommand = async function() {
|
||||
|
||||
payload = { building_id };
|
||||
} else if (type === 'recruit') {
|
||||
const unit_id = document.getElementById('unit-select').value;
|
||||
const unit_id = window.selectedUnitId;
|
||||
if (!unit_id) return alert('Παρακαλώ επιλέξτε Μονάδα προς εκπαίδευση.');
|
||||
|
||||
const amount = parseInt(document.getElementById('recruit-amount').value) || 1;
|
||||
@@ -182,6 +182,30 @@ window.sendCommand = async function() {
|
||||
}
|
||||
|
||||
payload = { research_id };
|
||||
} else if (type === 'blueprints') {
|
||||
const blueprint_name = window.selectedBlueprintName;
|
||||
if (!blueprint_name) return alert('Παρακαλώ επιλέξτε Blueprint.');
|
||||
|
||||
try {
|
||||
const res = await fetch('/dashboard/blueprints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
town_id: town.town_id,
|
||||
blueprint_name: blueprint_name
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
alert(data.is_active ? 'Blueprint ενεργοποιήθηκε!' : 'Blueprint απενεργοποιήθηκε!');
|
||||
window.fetchTowns(); // refresh towns to update UI state
|
||||
} else {
|
||||
alert('Σφάλμα: ' + data.error);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Failed to toggle blueprint: ' + e);
|
||||
}
|
||||
return; // Exit here as we don't send this to /dashboard/commands
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -221,18 +245,18 @@ window.cancelCommand = async function(id) {
|
||||
|
||||
window.fetchCaptchaStatus = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/captcha-status?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(
|
||||
`/dashboard/captcha-status?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`
|
||||
);
|
||||
const data = await res.json();
|
||||
const banner = document.getElementById('captcha-banner');
|
||||
if (!banner) return;
|
||||
|
||||
if (data.captcha_active) {
|
||||
// Only show it if the user hasn't explicitly clicked 'close' for this specific alert
|
||||
if (banner.dataset.dismissed !== '1') {
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
// Captcha cleared from the game - hide banner and reset dismiss state for next time
|
||||
banner.style.display = 'none';
|
||||
banner.dataset.dismissed = '0';
|
||||
}
|
||||
|
||||
@@ -2,13 +2,106 @@
|
||||
// Command Form Component
|
||||
// ================================================================
|
||||
|
||||
window.onCmdTypeChange = function() {
|
||||
const type = document.getElementById('cmd-type').value;
|
||||
document.getElementById('build-options').style.display = type === 'build' ? '' : 'none';
|
||||
document.getElementById('recruit-options').style.display = type === 'recruit' ? '' : 'none';
|
||||
document.getElementById('amount-group').style.display = type === 'recruit' ? '' : 'none';
|
||||
document.getElementById('market-options').style.display = type === 'market_offer' ? '' : 'none';
|
||||
document.getElementById('research-options').style.display = type === 'research' ? '' : 'none';
|
||||
window.currentCmdType = null;
|
||||
|
||||
window.setCmdType = function(type, openModal = false) {
|
||||
window.currentCmdType = type;
|
||||
|
||||
// Update segmented control active state
|
||||
document.querySelectorAll('.seg-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const activeBtn = document.getElementById('seg-' + (type === 'market_offer' ? 'market' : type));
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
|
||||
// Show selection area
|
||||
document.getElementById('selection-area').style.display = 'flex';
|
||||
document.getElementById('recruit-amount-wrap').style.display = type === 'recruit' ? 'flex' : 'none';
|
||||
|
||||
// Update selection label with current choice if any, or default
|
||||
window.updateSelectionDisplay();
|
||||
|
||||
if (openModal) {
|
||||
window.reopenActiveModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.updateSelectionDisplay = function() {
|
||||
const type = window.currentCmdType;
|
||||
const labelEl = document.getElementById('selection-label');
|
||||
|
||||
if (type === 'build') {
|
||||
if (window.selectedBuildingId) {
|
||||
labelEl.innerHTML = `🏗️ ${window.BUILDING_NAMES_GR[window.selectedBuildingId] || window.selectedBuildingId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Κατασκευή --`;
|
||||
}
|
||||
} else if (type === 'recruit') {
|
||||
if (window.selectedUnitId) {
|
||||
labelEl.innerHTML = `⚔️ ${window.UNIT_NAMES_GR[window.selectedUnitId] || window.selectedUnitId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Μονάδα --`;
|
||||
}
|
||||
} else if (type === 'research') {
|
||||
if (window.selectedResearchId) {
|
||||
const rd = window.RESEARCH_DATA[window.selectedResearchId];
|
||||
labelEl.innerHTML = `🦉 ${rd ? rd.name : window.selectedResearchId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Έρευνα --`;
|
||||
}
|
||||
} else if (type === 'market_offer') {
|
||||
labelEl.innerHTML = `🛒 Ρυθμίσεις Παζαριού`;
|
||||
} else if (type === 'blueprints') {
|
||||
if (window.selectedBlueprintName) {
|
||||
labelEl.innerHTML = `📜 ${window.selectedBlueprintName}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Blueprint --`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.reopenActiveModal = function() {
|
||||
const type = window.currentCmdType;
|
||||
if (type === 'build') window.openBuildingModal();
|
||||
else if (type === 'recruit') window.openUnitModal();
|
||||
else if (type === 'research') window.openAcademyModal();
|
||||
else if (type === 'market_offer') window.openMarketModal();
|
||||
else if (type === 'blueprints') window.openBlueprintsModal();
|
||||
};
|
||||
|
||||
window.selectedBlueprintName = null;
|
||||
window.selectBlueprint = function(name) {
|
||||
window.selectedBlueprintName = name;
|
||||
window.updateSelectionDisplay();
|
||||
window.closeBlueprintsModal();
|
||||
};
|
||||
|
||||
window.openBlueprintsModal = function() {
|
||||
document.getElementById('blueprints-modal-overlay').classList.add('open');
|
||||
};
|
||||
window.closeBlueprintsModal = function(e) {
|
||||
if (e && e.target.id !== 'blueprints-modal-overlay' && !e.target.classList.contains('modal-close')) return;
|
||||
document.getElementById('blueprints-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.openMarketModal = function() {
|
||||
document.getElementById('market-modal-overlay').classList.add('open');
|
||||
};
|
||||
|
||||
window.closeMarketModal = function(e) {
|
||||
if (e && !e.target.classList.contains('modal-overlay') && !e.target.classList.contains('modal-close')) return;
|
||||
document.getElementById('market-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.saveMarketModal = function() {
|
||||
const oType = document.getElementById('market-offer-type').value;
|
||||
const oAmt = document.getElementById('market-offer-amount').value;
|
||||
const dType = document.getElementById('market-demand-type').value;
|
||||
const dAmt = document.getElementById('market-demand-amount').value;
|
||||
|
||||
const labelEl = document.getElementById('selection-label');
|
||||
const resGr = { wood: 'Ξύλο', stone: 'Πέτρα', iron: 'Ασήμι' };
|
||||
labelEl.innerHTML = `🛒 ${oAmt} ${resGr[oType]} ➔ ${dAmt} ${resGr[dType]}`;
|
||||
|
||||
window.closeMarketModal();
|
||||
};
|
||||
|
||||
// Building emoji icons for the visual grid
|
||||
@@ -118,11 +211,8 @@ window.closeBuildingModal = function(e) {
|
||||
|
||||
window.selectBuilding = function(key, nameGr) {
|
||||
window.selectedBuildingId = key;
|
||||
// Update the trigger button label
|
||||
document.getElementById('selected-building-label').textContent = `🏗️ ${nameGr}`;
|
||||
// Re-render grid to show new selection highlight
|
||||
window.updateSelectionDisplay();
|
||||
window.openBuildingModal();
|
||||
// Close after brief visual feedback
|
||||
setTimeout(() => document.getElementById('building-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
@@ -233,37 +323,79 @@ window.closeAcademyModal = function(e) {
|
||||
|
||||
window.selectResearch = function(key, name) {
|
||||
window.selectedResearchId = key;
|
||||
document.getElementById('selected-research-label').textContent = `🧪 ${name}`;
|
||||
window.updateSelectionDisplay();
|
||||
window.openAcademyModal();
|
||||
setTimeout(() => document.getElementById('academy-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
|
||||
|
||||
window.selectedUnitId = null;
|
||||
|
||||
window.UNIT_ICONS = {
|
||||
sword: '⚔️', slinger: '🪨', archer: '🏹', hoplite: '🛡️',
|
||||
rider: '🐎', chariot: '🛷', catapult: '☄️', godsent: '👼',
|
||||
big_transporter: '⛴️', small_transporter: '🚤', bireme: '🛶',
|
||||
attack_ship: '🔥', trireme: '🔱', colonize_ship: '⚓',
|
||||
medusa: '🐍', zyklop: '👁️', harpy: '🦅', pegasus: '🐴',
|
||||
minotaur: '🐂', manticore: '🦁', cerberus: '🐕',
|
||||
hydra: '🐉', sea_monster: '🦑', militia: '🧑🌾'
|
||||
};
|
||||
|
||||
window.renderUnitDropdown = function() {
|
||||
// No-op - selection now happens via modal
|
||||
};
|
||||
|
||||
window.openUnitModal = function() {
|
||||
const town = window.getSelectedTown();
|
||||
if (!town) return;
|
||||
const uSelect = document.getElementById('unit-select');
|
||||
const grid = document.getElementById('unit-grid');
|
||||
const uData = town.unit_data || {};
|
||||
|
||||
const currentVal = uSelect.value;
|
||||
uSelect.innerHTML = '<option value="" disabled selected>-- Επιλέξτε Μονάδα --</option>';
|
||||
// Group units into categories for better display
|
||||
const categories = {
|
||||
'Ξηρά': ['sword', 'slinger', 'archer', 'hoplite', 'rider', 'chariot', 'catapult', 'godsent'],
|
||||
'Ναυτικές': ['big_transporter', 'small_transporter', 'bireme', 'attack_ship', 'trireme', 'colonize_ship'],
|
||||
'Μυθικές': ['medusa', 'zyklop', 'harpy', 'pegasus', 'minotaur', 'manticore', 'cerberus', 'hydra', 'sea_monster']
|
||||
};
|
||||
|
||||
for (const [key, nameGr] of Object.entries(window.UNIT_NAMES_GR)) {
|
||||
let html = '';
|
||||
|
||||
for (const [catName, units] of Object.entries(categories)) {
|
||||
let catHtml = '';
|
||||
|
||||
for (const key of units) {
|
||||
if (key === 'militia') continue;
|
||||
|
||||
const nameGr = window.UNIT_NAMES_GR[key] || key;
|
||||
const data = uData[key];
|
||||
let text = `${nameGr}`;
|
||||
const icon = window.UNIT_ICONS[key] || '💂';
|
||||
const isSelected = key === window.selectedUnitId;
|
||||
|
||||
let statusClass, statusLabel, cardClass = '';
|
||||
let costStr = '';
|
||||
let clickable = false;
|
||||
|
||||
const requiredGod = window.UNIT_GODS ? window.UNIT_GODS[key] : null;
|
||||
const isWrongGod = requiredGod && town.god !== requiredGod;
|
||||
const isNoGod = key === 'godsent' && !town.god;
|
||||
|
||||
if (isWrongGod) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Άλλος Θεός'; cardClass = 'bld-locked';
|
||||
const greekGods = { zeus: 'Δία', poseidon: 'Ποσειδώνα', hera: 'Ήρα', athena: 'Αθηνά', hades: 'Άδη', artemis: 'Άρτεμις', aphrodite: 'Αφροδίτη', ares: 'Άρη' };
|
||||
costStr = `Απαιτεί: ${greekGods[requiredGod] || requiredGod}`;
|
||||
} else if (isNoGod) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Χωρίς Θεό'; cardClass = 'bld-locked';
|
||||
costStr = `Απαιτείται Ναός`;
|
||||
} else if (data) {
|
||||
const missingKeys = data.missing_dependencies ? Object.keys(data.missing_dependencies) : [];
|
||||
const isLocked = missingKeys.length > 0;
|
||||
|
||||
if (data) {
|
||||
const w = window.fmt(data.wood || 0);
|
||||
const st = window.fmt(data.stone || 0);
|
||||
const i = window.fmt(data.iron || 0);
|
||||
const pop = data.pop || 0;
|
||||
|
||||
// Unit build_time is usually raw seconds in GameData
|
||||
let t = data.build_time || 0;
|
||||
let tStr = `${t}s`;
|
||||
if (t > 60) {
|
||||
@@ -272,38 +404,59 @@ window.renderUnitDropdown = function() {
|
||||
tStr = `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
const costStr = `Ξ:${w} Π:${st} Α:${i} 🧔:${pop} · ⏱ ${tStr}`;
|
||||
|
||||
const missingKeys = data.missing_dependencies ? Object.keys(data.missing_dependencies) : [];
|
||||
const isLocked = missingKeys.length > 0;
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
// 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) {
|
||||
option.textContent = `${text} — 🔒 Κλειδωμένο`;
|
||||
option.style.color = '#ff4444';
|
||||
statusClass = 'locked'; statusLabel = '🔒 Κλειδωμένο'; cardClass = 'bld-locked';
|
||||
} else if (data.enough_resources === false) {
|
||||
option.textContent = `${text} — ❌ ${costStr} (Λείπουν Πόροι 1x)`;
|
||||
option.style.color = '#aa5555';
|
||||
statusClass = 'no-resources'; statusLabel = '❌ Λείπουν Πόροι';
|
||||
} else {
|
||||
option.textContent = `${text} — ✅ ${costStr}`;
|
||||
statusClass = 'can-build'; statusLabel = '✅ Διαθέσιμο';
|
||||
clickable = true;
|
||||
}
|
||||
} else {
|
||||
// If no data is available for this unit, treat it as locked/unknown
|
||||
statusClass = 'locked'; statusLabel = '🔒 Άγνωστο'; cardClass = 'bld-locked';
|
||||
}
|
||||
|
||||
uSelect.appendChild(option);
|
||||
} else {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = text;
|
||||
uSelect.appendChild(option);
|
||||
const onclick = clickable ? `onclick="window.selectUnit('${key}', '${nameGr}')"` : '';
|
||||
|
||||
catHtml += `<div class="bld-card ${cardClass}${isSelected ? ' bld-selected' : ''}" ${onclick} style="width:140px; justify-content:flex-start;">
|
||||
<span class="bld-icon">${icon}</span>
|
||||
<span class="bld-name" style="margin-top:6px; font-size:0.85rem;">${nameGr}</span>
|
||||
<span class="bld-status ${statusClass}">${statusLabel}</span>
|
||||
<span class="bld-cost" style="font-size:0.65rem;">${costStr}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (catHtml) {
|
||||
html += `<div style="width:100%; margin-top:10px;"><h4 style="color:#c8a44a; margin-bottom:10px; border-bottom:1px solid #2a4a6a; padding-bottom:4px;">${catName}</h4>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px;">${catHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentVal && Array.from(uSelect.options).some(o => o.value === currentVal)) {
|
||||
uSelect.value = currentVal;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
document.getElementById('unit-modal-overlay').classList.add('open');
|
||||
};
|
||||
|
||||
window.closeUnitModal = function(e) {
|
||||
if (e && e.target !== document.getElementById('unit-modal-overlay') && e.target !== document.getElementById('unit-modal-close')) return;
|
||||
document.getElementById('unit-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.selectUnit = function(key, nameGr) {
|
||||
window.selectedUnitId = key;
|
||||
window.updateSelectionDisplay();
|
||||
window.openUnitModal();
|
||||
setTimeout(() => document.getElementById('unit-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
|
||||
|
||||
window.renderBuildQueuePreview = function() {
|
||||
const town = window.getSelectedTown();
|
||||
const el = document.getElementById('build-queue-preview');
|
||||
|
||||
@@ -12,8 +12,7 @@ window.renderTowns = function() {
|
||||
// Get active filters
|
||||
const searchTerm = (document.getElementById('town-search')?.value || '').toLowerCase();
|
||||
const reqFullWh = document.getElementById('filter-full-wh')?.checked;
|
||||
const reqFestival = document.getElementById('filter-festival')?.checked;
|
||||
const reqPoints = document.getElementById('filter-points')?.checked;
|
||||
const reqNotBuilding = document.getElementById('filter-not-building')?.checked;
|
||||
|
||||
const filteredTowns = window.towns.filter(t => {
|
||||
// 1. Search by name
|
||||
@@ -29,13 +28,8 @@ window.renderTowns = function() {
|
||||
// 2. Full WH (>95%)
|
||||
if (reqFullWh && Math.max(wPct, sPct, iPct) < 0.95) return false;
|
||||
|
||||
// 3. Festival (Wood>15k, Stone>18k, Iron>15k)
|
||||
// City Festival exact costs = 15000, 18000, 15000
|
||||
if (reqFestival && ((res.wood || 0) < 15000 || (res.stone || 0) < 18000 || (res.iron || 0) < 15000)) return false;
|
||||
|
||||
// 4. Large Points
|
||||
const pts = typeof t.points === 'number' ? t.points : parseInt(t.points) || 0;
|
||||
if (reqPoints && pts < 10000) return false;
|
||||
// 3. Not Building (no items in build_queue)
|
||||
if (reqNotBuilding && t.build_queue && t.build_queue.length > 0) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -62,9 +56,6 @@ window.renderTowns = function() {
|
||||
if (wPct >= 0.95 || sPct >= 0.95 || iPct >= 0.95) {
|
||||
markers += '<span title="Γεμάτη Αποθήκη!" style="margin-right:4px;">⚠️</span>';
|
||||
}
|
||||
if ((res.wood || 0) >= 15000 && (res.stone || 0) >= 18000 && (res.iron || 0) >= 15000) {
|
||||
markers += '<span title="Αρκετοί πόροι για Φεστιβάλ!" style="margin-right:4px;">🎭</span>';
|
||||
}
|
||||
|
||||
const getC = (pct) => pct >= 0.95 ? 'color:#ff4a4a;font-weight:bold;' : '';
|
||||
|
||||
@@ -221,4 +212,43 @@ window.renderTownDetails = function() {
|
||||
if(unitsHtml === '') unitsHtml = '<div style="color:#666">Κανένα στράτευμα</div>';
|
||||
|
||||
document.getElementById('td-units').innerHTML = unitsHtml;
|
||||
|
||||
// ---- Blueprint Lock & Indicator ----
|
||||
const isBlueprintActive = !!t.blueprint_active;
|
||||
const bpName = t.blueprint_name || 'Standard Growth';
|
||||
|
||||
if (isBlueprintActive) {
|
||||
document.getElementById('td-general').innerHTML += `
|
||||
<div style="margin-top:10px; padding: 4px 8px; background: rgba(200,164,74,0.15); border-left: 3px solid #c8a44a; border-radius:4px; font-size:0.8rem;">
|
||||
🤖 Blueprint: <strong style="color:#c8a44a">${bpName}</strong> <span style="color:#2ecc71">(ΕΝΕΡΓΟ)</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const btnBuild = document.getElementById('seg-build');
|
||||
const btnResearch = document.getElementById('seg-research');
|
||||
|
||||
if (btnBuild && btnResearch) {
|
||||
if (isBlueprintActive) {
|
||||
btnBuild.style.opacity = '0.3';
|
||||
btnBuild.style.pointerEvents = 'none';
|
||||
btnBuild.title = 'Απενεργοποιημένο λόγω Blueprint';
|
||||
|
||||
btnResearch.style.opacity = '0.3';
|
||||
btnResearch.style.pointerEvents = 'none';
|
||||
btnResearch.title = 'Απενεργοποιημένο λόγω Blueprint';
|
||||
|
||||
if (window.currentCmdType === 'build' || window.currentCmdType === 'research') {
|
||||
window.setCmdType('recruit');
|
||||
}
|
||||
} else {
|
||||
btnBuild.style.opacity = '1';
|
||||
btnBuild.style.pointerEvents = 'auto';
|
||||
btnBuild.title = '';
|
||||
|
||||
btnResearch.style.opacity = '1';
|
||||
btnResearch.style.pointerEvents = 'auto';
|
||||
btnResearch.title = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,8 +24,8 @@ window.BUILDING_NAMES_GR = {
|
||||
};
|
||||
|
||||
window.UNIT_NAMES_GR = {
|
||||
sword: "Ξιφομάχος", slinger: "Σφενδονήτης", archer: "Τοξότης", hoplite: "Οπλίτης",
|
||||
rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης",
|
||||
sword: "Ξιφομάχος", slinger: "Εκσφενδονιστής", archer: "Τοξότης", hoplite: "Οπλίτης",
|
||||
rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης", godsent: "Θεόσταλτος",
|
||||
big_transporter: "Μεταφορικό", small_transporter: "Γρήγ. Μεταφορικό", bireme: "Διήρης",
|
||||
attack_ship: "Πλοίο Φάρος", trireme: "Τριήρης", colonize_ship: "Αποικιακό",
|
||||
medusa: "Μέδουσα", zyklop: "Κύκλωπας", harpy: "Άρπυια", pegasus: "Πήγασος",
|
||||
@@ -33,6 +33,17 @@ window.UNIT_NAMES_GR = {
|
||||
hydra: "Ύδρα", sea_monster: "Τέρας Θάλασσας", militia: "Εθνοφρουρά"
|
||||
};
|
||||
|
||||
window.UNIT_GODS = {
|
||||
minotaur: 'zeus', manticore: 'zeus',
|
||||
zyklop: 'poseidon', hydra: 'poseidon',
|
||||
harpy: 'hera', medusa: 'hera',
|
||||
pegasus: 'athena', centaur: 'athena',
|
||||
cerberus: 'hades', erinys: 'hades',
|
||||
griffon: 'artemis', calydonian_boar: 'artemis',
|
||||
siren: 'aphrodite', satyr: 'aphrodite',
|
||||
spartoi: 'ares', ladon: 'ares'
|
||||
};
|
||||
|
||||
window.RES_ICONS = {
|
||||
wood: '<span class="res-icon res-wood" style="display:inline-block; margin-right:4px;"></span>',
|
||||
stone: '<span class="res-icon res-stone" style="display:inline-block; margin-right:4px;"></span>',
|
||||
|
||||
462
templates/attack_planner.html
Normal file
462
templates/attack_planner.html
Normal file
@@ -0,0 +1,462 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Attack Planner — Grepolis Remote</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0f0f1a;--surf:#181824;--border:#2a2a40;--text:#e0e0e0;--muted:#666;
|
||||
--gold:#c8a44a;--red:#e05555;--green:#4acc64;--blue:#6fcfcf;--yellow:#f0c040;--orange:#e07830}
|
||||
body{min-height:100vh;background:var(--bg);font-family:'Inter',sans-serif;color:var(--text);padding:1.5rem 2rem}
|
||||
.topbar{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}
|
||||
.topbar h1{font-size:1.6rem;font-weight:800;background:linear-gradient(135deg,#c8a44a,#f0c96e);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;flex:1}
|
||||
.back{color:var(--muted);text-decoration:none;font-size:.85rem}
|
||||
.back:hover{color:var(--text)}
|
||||
.layout{display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start}
|
||||
@media(max-width:900px){.layout{grid-template-columns:1fr}}
|
||||
.card{background:var(--surf);border:1px solid var(--border);border-radius:14px;padding:1.25rem;margin-bottom:1rem}
|
||||
.ct{font-size:.9rem;font-weight:700;color:var(--gold);margin-bottom:.85rem;padding-bottom:.6rem;border-bottom:1px solid var(--border)}
|
||||
label{display:block;font-size:.78rem;color:var(--muted);margin-bottom:.25rem;margin-top:.65rem}
|
||||
label:first-of-type{margin-top:0}
|
||||
input,select{width:100%;padding:8px 11px;background:#0f0f1a;border:1px solid var(--border);
|
||||
border-radius:7px;color:var(--text);font-size:.85rem;font-family:inherit}
|
||||
input:focus,select:focus{outline:none;border-color:var(--gold)}
|
||||
select option{background:#181824}
|
||||
.btn{padding:8px 16px;border:none;border-radius:7px;font-family:inherit;font-weight:600;font-size:.82rem;cursor:pointer;transition:all .2s}
|
||||
.btn-gold{background:var(--gold);color:#0f0f1a}.btn-gold:hover{background:#e0b85a}
|
||||
.btn-red{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.35)}.btn-red:hover{background:rgba(224,85,85,.25)}
|
||||
.btn-green{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.35)}.btn-green:hover{background:rgba(74,204,100,.25)}
|
||||
.btn-blue{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}.btn-blue:hover{background:rgba(111,207,207,.22)}
|
||||
.btn-sm{padding:4px 11px;font-size:.75rem}
|
||||
.mt{margin-top:.65rem}
|
||||
.sep{height:1px;background:var(--border);margin:.85rem 0}
|
||||
table{width:100%;border-collapse:collapse;font-size:.8rem}
|
||||
th{padding:7px 10px;text-align:left;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
|
||||
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.plan-row{cursor:pointer}.plan-row:hover td{background:rgba(200,164,74,.04)}
|
||||
.plan-row.selected td{background:rgba(200,164,74,.08)}
|
||||
.badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:.7rem;font-weight:700}
|
||||
.badge-draft{background:rgba(102,102,102,.2);color:#888;border:1px solid #444}
|
||||
.badge-active{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
|
||||
.badge-cancelled{background:rgba(224,85,85,.1);color:var(--red);border:1px solid rgba(224,85,85,.2)}
|
||||
.badge-completed{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}
|
||||
.badge-pending{background:rgba(240,192,64,.1);color:var(--yellow);border:1px solid rgba(240,192,64,.3)}
|
||||
.badge-armed{background:rgba(224,120,48,.12);color:var(--orange);border:1px solid rgba(224,120,48,.3)}
|
||||
.badge-sent{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
|
||||
.badge-missed{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.3)}
|
||||
.empty{text-align:center;padding:1.5rem;color:var(--muted);font-size:.85rem}
|
||||
.err{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--red);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
|
||||
.ok{background:rgba(111,207,207,.08);border:1px solid rgba(111,207,207,.25);color:var(--blue);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
|
||||
.warn{background:rgba(240,192,64,.07);border:1px solid rgba(240,192,64,.25);color:var(--yellow);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.5rem}
|
||||
.unit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(115px,1fr));gap:.4rem;margin-top:.5rem}
|
||||
.ui{display:flex;flex-direction:column;gap:2px}
|
||||
.ui label{font-size:.7rem;margin:0}
|
||||
.ui input{padding:5px 7px;font-size:.8rem}
|
||||
.town-meta{font-size:.73rem;color:var(--muted);margin-top:3px}
|
||||
.plan-header{display:flex;gap:.5rem;align-items:flex-start;flex-wrap:wrap;margin-bottom:.85rem}
|
||||
.plan-stat{background:#0f0f1a;border:1px solid var(--border);border-radius:8px;padding:8px 13px;font-size:.78rem;flex:1;min-width:120px}
|
||||
.plan-stat strong{display:block;font-size:1rem;color:var(--gold);font-weight:700}
|
||||
.cd{font-family:monospace;font-weight:700;color:var(--yellow)}
|
||||
.fok{color:var(--green)}.ferr{color:var(--red)}
|
||||
.section-label{font-size:.78rem;font-weight:700;color:var(--gold);margin-bottom:.4rem}
|
||||
#msg{display:none;margin-top:.65rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.__GRC_CLAN_KEY = "{{ clan_key }}";
|
||||
window.PLAYER_ID = "{{ player_id }}";
|
||||
window.WORLD_ID = "{{ world_id }}";
|
||||
</script>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}" class="back">← Hub</a>
|
||||
<h1>⚔️ Attack Planner — {{ world_id }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<!-- ═══════════════ LEFT: Plan list + Create ═══════════════ -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="ct">➕ Νέο Πλάνο</div>
|
||||
<label>Όνομα Πλάνου</label>
|
||||
<input id="p-name" placeholder="π.χ. Επίθεση στον Leonidas">
|
||||
<label>Στόχος (όνομα)</label>
|
||||
<input id="p-tname" placeholder="π.χ. Athens Colony">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||
<div><label>Στόχος X</label><input id="p-tx" type="number" min="0" max="999"></div>
|
||||
<div><label>Στόχος Y</label><input id="p-ty" type="number" min="0" max="999"></div>
|
||||
</div>
|
||||
<label>Ώρα Άφιξης (τοπική)</label>
|
||||
<input id="p-arr" type="datetime-local">
|
||||
<div class="warn">⏱ Τουλάχιστον 2 λεπτά στο μέλλον</div>
|
||||
<div class="mt"><button class="btn btn-gold" onclick="createPlan()">Δημιουργία →</button></div>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ct">📋 Πλάνα — {{ world_id }}</div>
|
||||
<div id="plans-list"><div class="empty">Φόρτωση…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ RIGHT: Plan detail + Add participant ═══════════════ -->
|
||||
<div id="right-panel">
|
||||
<div class="card" style="border-color:#2a3060">
|
||||
<div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const PID = window.PLAYER_ID;
|
||||
const WID = window.WORLD_ID;
|
||||
const KEY = window.__GRC_CLAN_KEY;
|
||||
let selPlan = null;
|
||||
let allTowns = []; // all clan towns for this world
|
||||
|
||||
function hdrs(){ return {'Content-Type':'application/json','X-Clan-Key':KEY}; }
|
||||
function ts(u){ return u ? new Date(u*1000).toLocaleString('el-GR') : '–'; }
|
||||
function badge(s){ return `<span class="badge badge-${s}">${s}</span>`; }
|
||||
function showMsg(el,txt,err){el.style.display='block';el.className=err?'err':'ok';el.textContent=txt;}
|
||||
|
||||
// ─── unit groups ───
|
||||
const LAND = ['swordsman','slinger','archer','hoplite','horseman','chariot','catapult'];
|
||||
const NAVAL = ['bireme','attack_ship','demolition_ship','transport_ship','colonize_ship'];
|
||||
|
||||
// ─── Load all clan towns for this world ───
|
||||
async function loadClanTowns(){
|
||||
try{
|
||||
const r = await fetch(`/dashboard/clan-towns?world_id=${WID}`);
|
||||
allTowns = await r.json();
|
||||
}catch(e){ allTowns=[]; }
|
||||
}
|
||||
|
||||
// ─── Load plans list ───
|
||||
async function loadPlans(){
|
||||
const r = await fetch(`/api/${WID}/attack_plans`);
|
||||
const data = await r.json();
|
||||
const el = document.getElementById('plans-list');
|
||||
if(!Array.isArray(data)||!data.length){
|
||||
el.innerHTML='<div class="empty">Δεν υπάρχουν πλάνα.</div>'; return;
|
||||
}
|
||||
let h=`<table><thead><tr><th>Πλάνο</th><th>Στόχος</th><th>Άφιξη</th><th>Status</th><th>Συμμ.</th></tr></thead><tbody>`;
|
||||
for(const p of data){
|
||||
const sel = selPlan===p.id ? ' selected':'';
|
||||
h+=`<tr class="plan-row${sel}" onclick="selectPlan(${p.id})">
|
||||
<td><strong>${p.plan_name}</strong></td>
|
||||
<td style="font-size:.75rem">${p.target_town_name||'–'} ${p.target_x?`(${p.target_x},${p.target_y})`:''}</td>
|
||||
<td style="font-size:.72rem">${ts(p.target_arrival_time)}</td>
|
||||
<td>${badge(p.status)}</td>
|
||||
<td style="text-align:center">${p.participant_count}</td>
|
||||
</tr>`;
|
||||
}
|
||||
h+='</tbody></table>';
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
// ─── Select plan ───
|
||||
window.selectPlan = async function(id){
|
||||
selPlan = id;
|
||||
await loadPlans();
|
||||
await renderPlanDetail(id);
|
||||
};
|
||||
|
||||
async function renderPlanDetail(id){
|
||||
const r = await fetch(`/api/${WID}/attack_plans/${id}`);
|
||||
const plan = await r.json();
|
||||
if(plan.error){ return; }
|
||||
|
||||
const parts = plan.participants||[];
|
||||
const isDraft = plan.status==='draft';
|
||||
|
||||
// Header stats
|
||||
let hdr = `
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΤΟΧΟΣ</span>
|
||||
<strong>${plan.target_town_name||'–'}</strong>
|
||||
<span style="font-size:.75rem;color:var(--muted)">(${plan.target_x||'?'}, ${plan.target_y||'?'})</span>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΑΦΙΞΗ</span>
|
||||
<strong style="font-size:.85rem">${ts(plan.target_arrival_time)}</strong>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">STATUS</span>
|
||||
<strong>${badge(plan.status)}</strong>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΥΜΜΕΤΕΧΟΝΤΕΣ</span>
|
||||
<strong>${parts.length}</strong>
|
||||
</div>`;
|
||||
|
||||
// Action buttons
|
||||
let btns = '';
|
||||
if(isDraft){
|
||||
btns=`<button class="btn btn-green btn-sm" onclick="armPlan(${id})">▶ ARM</button>`;
|
||||
}
|
||||
btns+=` <button class="btn btn-red btn-sm" onclick="cancelPlan(${id})">✕ Ακύρωση</button>`;
|
||||
|
||||
// Participants table
|
||||
let ptable = '<div class="empty">Χωρίς συμμετέχοντες ακόμη.</div>';
|
||||
if(parts.length){
|
||||
let latest = 0;
|
||||
let ptrows = '';
|
||||
for(const p of parts){
|
||||
if(p.return_time>latest) latest=p.return_time;
|
||||
const units = p.units ? Object.entries(p.units).filter(([,v])=>v>0).map(([k,v])=>`${k}:${v}`).join(', ') : '–';
|
||||
ptrows+=`<tr>
|
||||
<td>
|
||||
<div style="font-weight:600">${p.origin_town_name||p.origin_town_id}</div>
|
||||
<div style="font-size:.72rem;color:var(--muted)">${p.player_name||p.player_id||''}</div>
|
||||
</td>
|
||||
<td style="font-size:.72rem;color:var(--blue)">${units||'–'}</td>
|
||||
<td>${p.transport_needed?`🚢 ${p.transport_count}`:'–'}</td>
|
||||
<td style="font-size:.72rem">${ts(p.send_time)}</td>
|
||||
<td style="font-size:.72rem">${ts(p.return_time)}</td>
|
||||
<td>${badge(p.status)}</td>
|
||||
<td>${p.is_feasible?'<span class="fok">✅</span>':`<span class="ferr" title="${p.error_msg||''}">❌</span>`}</td>
|
||||
<td><button class="btn btn-red btn-sm" onclick="removeParticipant(${id},'${p.origin_town_id}')">✕</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
ptable=`
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Πόλη / Παίκτης</th><th>Στρατός</th><th>Πλοία</th>
|
||||
<th>Αποστολή</th><th>Επιστροφή</th><th>Status</th><th>OK</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>${ptrows}</tbody>
|
||||
</table>`;
|
||||
if(latest){
|
||||
ptable+=`<div class="ok" style="margin-top:.65rem">🏠 Τελευταία επιστροφή: <strong>${ts(latest)}</strong></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add participant form
|
||||
const addForm = buildAddForm(id, isDraft);
|
||||
|
||||
document.getElementById('right-panel').innerHTML=`
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.85rem">
|
||||
<div class="ct" style="margin:0;border:none;padding:0">📌 ${plan.plan_name}</div>
|
||||
<div style="display:flex;gap:.4rem">${btns}</div>
|
||||
</div>
|
||||
<div class="plan-header">${hdr}</div>
|
||||
<div class="sep"></div>
|
||||
<div class="section-label">👥 Συμμετέχοντες</div>
|
||||
${ptable}
|
||||
</div>
|
||||
${isDraft ? addForm : ''}
|
||||
`;
|
||||
|
||||
// Populate player dropdown
|
||||
if(isDraft) populatePlayerDropdown();
|
||||
}
|
||||
|
||||
// ─── Build "Add Participant" form ───
|
||||
function buildAddForm(planId, isDraft){
|
||||
if(!isDraft) return '';
|
||||
|
||||
// Build unit inputs (land + naval groups)
|
||||
let landInputs = LAND.map(u=>`
|
||||
<div class="ui"><label>${u}</label>
|
||||
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
|
||||
let navalInputs = NAVAL.map(u=>`
|
||||
<div class="ui"><label>${u}</label>
|
||||
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
|
||||
|
||||
return `
|
||||
<div class="card" id="add-form">
|
||||
<div class="ct">👤 Προσθήκη Συμμετέχοντα</div>
|
||||
|
||||
<label>Παίκτης</label>
|
||||
<select id="ap-player" onchange="onPlayerChange()">
|
||||
<option value="">— Επίλεξε παίκτη —</option>
|
||||
</select>
|
||||
|
||||
<label>Πόλη</label>
|
||||
<select id="ap-town" onchange="onTownChange()">
|
||||
<option value="">— Επίλεξε πόλη —</option>
|
||||
</select>
|
||||
<div class="town-meta" id="ap-meta"></div>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div class="section-label">🗡 Χερσαίος Στρατός</div>
|
||||
<div class="unit-grid">${landInputs}</div>
|
||||
|
||||
<div class="section-label" style="margin-top:.75rem">⚓ Ναυτικός Στρατός</div>
|
||||
<div class="unit-grid">${navalInputs}</div>
|
||||
|
||||
<div class="warn" style="margin-top:.65rem">
|
||||
💡 Αφήσε 0 σε μονάδες που δεν συμμετέχουν. Τα πλοία μεταφοράς υπολογίζονται αυτόματα.
|
||||
</div>
|
||||
|
||||
<div class="mt" style="display:flex;gap:.5rem">
|
||||
<button class="btn btn-gold" onclick="addParticipant(${planId})">Υπολογισμός & Προσθήκη</button>
|
||||
<button class="btn btn-blue" onclick="fillFromGame()">📥 Φόρτωση από game</button>
|
||||
</div>
|
||||
<div id="ap-result"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Populate player dropdown from allTowns ───
|
||||
function populatePlayerDropdown(){
|
||||
const sel = document.getElementById('ap-player');
|
||||
if(!sel) return;
|
||||
const seen = {};
|
||||
allTowns.forEach(t=>{ seen[t.player_id]=t.player; });
|
||||
while(sel.options.length>1) sel.remove(1);
|
||||
for(const [pid,pname] of Object.entries(seen)){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=pid; opt.textContent=pname||pid;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Player change → filter town dropdown ───
|
||||
window.onPlayerChange = function(){
|
||||
const pid = document.getElementById('ap-player').value;
|
||||
const tsel = document.getElementById('ap-town');
|
||||
while(tsel.options.length>1) tsel.remove(1);
|
||||
document.getElementById('ap-meta').textContent='';
|
||||
if(!pid) return;
|
||||
allTowns.filter(t=>t.player_id===pid).forEach(t=>{
|
||||
const opt=document.createElement('option');
|
||||
opt.value=t.town_id;
|
||||
opt.textContent=`${t.town_name} (${t.x}, ${t.y})`;
|
||||
tsel.appendChild(opt);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Town change → show meta + fill units from game data ───
|
||||
window.onTownChange = function(){
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
const meta = document.getElementById('ap-meta');
|
||||
if(!tid){ meta.textContent=''; return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town){ return; }
|
||||
meta.innerHTML=`🗺 X:<strong>${town.x}</strong> Y:<strong>${town.y}</strong> Sea:<strong>${town.sea}</strong>`;
|
||||
};
|
||||
|
||||
// ─── Fill units from game data (if available) ───
|
||||
window.fillFromGame = function(){
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
if(!tid){ return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town||!town.units) return;
|
||||
const all=[...LAND,...NAVAL];
|
||||
all.forEach(u=>{
|
||||
const el=document.getElementById(`u_${u}`);
|
||||
if(el && town.units[u]!==undefined) el.value=town.units[u]||0;
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Add participant ───
|
||||
window.addParticipant = async function(planId){
|
||||
const result=document.getElementById('ap-result');
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
if(!tid){ result.innerHTML='<div class="err">❌ Επίλεξε πόλη</div>'; return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town){ result.innerHTML='<div class="err">❌ Πόλη δεν βρέθηκε</div>'; return; }
|
||||
|
||||
const units={};
|
||||
[...LAND,...NAVAL].forEach(u=>{
|
||||
const v=parseInt(document.getElementById(`u_${u}`)?.value)||0;
|
||||
if(v>0) units[u]=v;
|
||||
});
|
||||
if(!Object.keys(units).length){ result.innerHTML='<div class="err">❌ Πρόσθεσε τουλάχιστον 1 μονάδα</div>'; return; }
|
||||
|
||||
const body={
|
||||
requester_player_id: PID,
|
||||
player_id: town.player_id,
|
||||
player_name: town.player,
|
||||
origin_town_id: town.town_id,
|
||||
origin_town_name: town.town_name,
|
||||
origin_x: town.x,
|
||||
origin_y: town.y,
|
||||
origin_sea: town.sea,
|
||||
units
|
||||
};
|
||||
|
||||
const r = await fetch(`/api/${WID}/attack_plans/${planId}/participants`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify(body)});
|
||||
const data = await r.json();
|
||||
|
||||
if(data.is_feasible!==undefined){
|
||||
if(data.is_feasible){
|
||||
result.innerHTML=`<div class="ok">
|
||||
✅ Feasible — Χρόνος: ${Math.floor(data.travel_time_secs/60)}λεπτά
|
||||
| Πλοία: ${data.transport_count||0}
|
||||
| Αποστολή: ${ts(data.send_time)}
|
||||
| Επιστροφή: ${ts(data.return_time)}
|
||||
</div>`;
|
||||
} else {
|
||||
result.innerHTML=`<div class="err">❌ ${data.error_msg}</div>`;
|
||||
}
|
||||
renderPlanDetail(planId);
|
||||
} else {
|
||||
result.innerHTML=`<div class="err">❌ ${data.error||'Σφάλμα'}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Remove participant ───
|
||||
window.removeParticipant = async function(planId,townId){
|
||||
if(!confirm('Αφαίρεση;')) return;
|
||||
await fetch(`/api/${WID}/attack_plans/${planId}/participants/${townId}`,
|
||||
{method:'DELETE', headers:hdrs(), body:JSON.stringify({requester_player_id:PID})});
|
||||
renderPlanDetail(planId); loadPlans();
|
||||
};
|
||||
|
||||
// ─── Arm plan ───
|
||||
window.armPlan = async function(id){
|
||||
const r=await fetch(`/api/${WID}/attack_plans/${id}/arm`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
|
||||
const d=await r.json();
|
||||
alert(d.ok?'✅ Πλάνο ενεργοποιήθηκε!':'❌ '+d.error);
|
||||
renderPlanDetail(id); loadPlans();
|
||||
};
|
||||
|
||||
// ─── Cancel plan ───
|
||||
window.cancelPlan = async function(id){
|
||||
if(!confirm('Ακύρωση πλάνου;')) return;
|
||||
await fetch(`/api/${WID}/attack_plans/${id}/cancel`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
|
||||
selPlan=null; loadPlans();
|
||||
document.getElementById('right-panel').innerHTML=
|
||||
'<div class="card" style="border-color:#2a3060"><div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div></div>';
|
||||
};
|
||||
|
||||
// ─── Create plan ───
|
||||
window.createPlan = async function(){
|
||||
const msg=document.getElementById('msg');
|
||||
const dt=document.getElementById('p-arr').value;
|
||||
if(!dt){ showMsg(msg,'Επίλεξε ώρα άφιξης',true); return; }
|
||||
const r=await fetch(`/api/${WID}/attack_plans`,{method:'POST',headers:hdrs(),body:JSON.stringify({
|
||||
player_id:PID,
|
||||
plan_name:document.getElementById('p-name').value.trim()||'Επίθεση',
|
||||
target_town_name:document.getElementById('p-tname').value.trim(),
|
||||
target_x:parseFloat(document.getElementById('p-tx').value)||null,
|
||||
target_y:parseFloat(document.getElementById('p-ty').value)||null,
|
||||
target_arrival_time:Math.floor(new Date(dt).getTime()/1000)
|
||||
})});
|
||||
const d=await r.json();
|
||||
if(d.ok){ showMsg(msg,`✅ Πλάνο δημιουργήθηκε (ID:${d.plan_id})`,false); loadPlans(); }
|
||||
else showMsg(msg,'❌ '+d.error,true);
|
||||
};
|
||||
|
||||
// ─── Init ───
|
||||
async function init(){
|
||||
await loadClanTowns();
|
||||
await loadPlans();
|
||||
setInterval(loadPlans, 15000);
|
||||
setInterval(()=>{ if(selPlan) renderPlanDetail(selPlan); }, 15000);
|
||||
}
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,8 +9,9 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1><a href="/" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Back to Players">⬅️</a> ⚔️ Grepolis Remote</h1>
|
||||
<div class="status-indicator">
|
||||
<h1><a href="/player/{{ player_id }}/{{ world_id }}" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Πίσω στο Hub">⬅️</a> ⚔️ Grepolis Remote</h1>
|
||||
<div class="status-indicator" style="display: flex; align-items: center; gap: 10px;">
|
||||
<button class="btn btn-gold btn-sm" id="live-btn" onclick="window.requestLiveSync()" title="Request immediate data update from game" style="padding: 4px 8px; font-size: 0.72rem; border-radius: 4px; border: 1px solid #c8a44a;">⚡ Live Sync</button>
|
||||
<div id="server-status" class="conn-badge">Server…</div>
|
||||
<div id="client-status" class="conn-badge">Client…</div>
|
||||
</div>
|
||||
@@ -29,7 +30,6 @@
|
||||
<div id="town-panel">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h2 style="margin: 0;">Towns</h2>
|
||||
<button class="btn btn-gold btn-sm" id="live-btn" onclick="window.requestLiveSync()" title="Request immediate data update from game" style="padding: 4px 8px; font-size: 0.72rem;">⚡ Live Sync</button>
|
||||
</div>
|
||||
|
||||
<div id="town-filters" style="margin-bottom: 15px; padding: 10px; background: #0f3460; border-radius: 6px; border: 1px solid #2a4a6a;">
|
||||
@@ -41,11 +41,7 @@
|
||||
</label>
|
||||
|
||||
<label style="font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 4px; background: rgba(0,0,0,0.2); padding: 4px 8px; border-radius: 12px; border: 1px solid #2a4a6a;">
|
||||
<input type="checkbox" id="filter-festival" onchange="window.renderTowns()"> 🎭 Ελεύθεροι Πόροι
|
||||
</label>
|
||||
|
||||
<label style="font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 4px; background: rgba(0,0,0,0.2); padding: 4px 8px; border-radius: 12px; border: 1px solid #2a4a6a;">
|
||||
<input type="checkbox" id="filter-points" onchange="window.renderTowns()"> 📈 10k+ Πόντοι
|
||||
<input type="checkbox" id="filter-not-building" onchange="window.renderTowns()"> 🏗️ Δεν χτίζει
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,132 +83,31 @@
|
||||
</div>
|
||||
|
||||
<div id="command-form-wrap" style="display:none">
|
||||
<div class="command-form">
|
||||
<div class="command-form" style="display: flex; flex-direction: column; gap: 15px; align-items: flex-start;">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Command Type</label>
|
||||
<select id="cmd-type" onchange="onCmdTypeChange()">
|
||||
<option value="" disabled selected>-- Επιλέξτε --</option>
|
||||
<option value="build">Build / Upgrade</option>
|
||||
<option value="recruit">Recruit Troops</option>
|
||||
<option value="market_offer">Παζάρι - Προσφορά</option>
|
||||
<option value="research">Ακαδημία - Έρευνες</option>
|
||||
</select>
|
||||
<!-- Segmented Control Row -->
|
||||
<div class="segmented-control" id="cmd-type-buttons" style="display: flex; gap: 5px; background: #16213e; padding: 4px; border-radius: 8px; border: 1px solid #2a4a6a;">
|
||||
<button class="seg-btn" id="seg-build" onclick="window.setCmdType('build', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🏗️ Κατασκευές</button>
|
||||
<button class="seg-btn" id="seg-recruit" onclick="window.setCmdType('recruit', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">⚔️ Στρατός</button>
|
||||
<button class="seg-btn" id="seg-market" onclick="window.setCmdType('market_offer', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🛒 Παζάρι</button>
|
||||
<button class="seg-btn" id="seg-research" onclick="window.setCmdType('research', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🦉 Έρευνα</button>
|
||||
<button class="seg-btn" id="seg-blueprints" onclick="window.setCmdType('blueprints', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">📜 Blueprints</button>
|
||||
</div>
|
||||
|
||||
<!-- Build options - now a button that opens the visual picker -->
|
||||
<div class="form-group" id="build-options" style="display:none">
|
||||
<label>Building</label>
|
||||
<button class="btn btn-gold" id="open-building-modal" onclick="window.openBuildingModal()" style="text-align:left; min-width:200px;">
|
||||
<span id="selected-building-label">-- Επιλέξτε Κατασκευή --</span>
|
||||
<!-- Dynamic Selection Area -->
|
||||
<div id="selection-area" style="display:none; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="btn btn-gold" id="active-selection-display" onclick="window.reopenActiveModal()" style="min-width: 250px; padding: 10px 15px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; font-weight: bold;">
|
||||
<span id="selection-label">-- Επιλέξτε --</span>
|
||||
</button>
|
||||
|
||||
<div id="recruit-amount-wrap" style="display:none; align-items: center; gap: 5px; background: #16213e; padding: 8px 12px; border-radius: 6px; border: 1px solid #2a4a6a;">
|
||||
<label style="font-size:0.85rem; color:#ccc; margin:0;">Ποσότητα:</label>
|
||||
<input type="number" id="recruit-amount" value="1" min="1" max="9999" style="width: 70px; background: #0f3460; color: #fff; border: 1px solid #c8a44a; border-radius: 4px; padding: 4px 8px;">
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<button id="btn-send" class="btn btn-gold" onclick="window.sendCommand()" style="margin-left: auto; padding: 10px 20px; font-size: 1rem;">Send ⚡</button>
|
||||
</div>
|
||||
|
||||
<!-- Recruit options -->
|
||||
<div class="form-group" id="recruit-options" style="display:none">
|
||||
<label>Unit</label>
|
||||
<select id="unit-select">
|
||||
<optgroup label="Ξηρά">
|
||||
<option value="sword">Ξιφομάχος</option>
|
||||
<option value="slinger">Σφενδονήτης</option>
|
||||
<option value="archer">Τοξότης</option>
|
||||
<option value="hoplite">Οπλίτης</option>
|
||||
<option value="rider">Ιππέας</option>
|
||||
<option value="chariot">Άρμα</option>
|
||||
<option value="catapult">Καταπέλτης</option>
|
||||
</optgroup>
|
||||
<optgroup label="Ναυτικές">
|
||||
<option value="big_transporter">Μεταφορικό Πλοίο</option>
|
||||
<option value="small_transporter">Γρήγορο Μεταφορικό Πλοίο</option>
|
||||
<option value="bireme">Διήρης</option>
|
||||
<option value="attack_ship">Πλοίο Φάρος</option>
|
||||
<option value="trireme">Τριήρης</option>
|
||||
<option value="colonize_ship">Αποικιακό Πλοίο</option>
|
||||
</optgroup>
|
||||
<optgroup label="Μυθικές">
|
||||
<option value="medusa">Μέδουσα</option>
|
||||
<option value="zyklop">Κύκλωπας</option>
|
||||
<option value="harpy">Άρπυια</option>
|
||||
<option value="pegasus">Πήγασος</option>
|
||||
<option value="minotaur">Μινώταυρος</option>
|
||||
<option value="manticore">Μαντιχώρας</option>
|
||||
<option value="cerberus">Κέρβερος</option>
|
||||
<option value="hydra">Ύδρα</option>
|
||||
<option value="sea_monster">Τέρας της Θάλασσας</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Market options -->
|
||||
<!-- Market options -->
|
||||
<div class="form-group" id="market-options" style="display:none">
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label>Προσφορά</label>
|
||||
<input type="number" id="market-offer-amount" value="1000" min="1" max="99999" style="width:100%;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Πόρος Προσφοράς</label>
|
||||
<select id="market-offer-type">
|
||||
<option value="wood">Ξύλο</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="iron">Ασήμι</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label>Ζήτηση</label>
|
||||
<input type="number" id="market-demand-amount" value="1000" min="1" max="99999" style="width:100%;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Πόρος Ζήτησης</label>
|
||||
<select id="market-demand-type">
|
||||
<option value="iron">Ασήμι</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="wood">Ξύλο</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<div style="flex:1;">
|
||||
<label>Χρόνος (Ώρες)</label>
|
||||
<select id="market-max-time">
|
||||
<option value="1800">0.5</option>
|
||||
<option value="3600">1</option>
|
||||
<option value="7200">2</option>
|
||||
<option value="14400">4</option>
|
||||
<option value="28800">8</option>
|
||||
<option value="43200">12</option>
|
||||
<option value="86400">24</option>
|
||||
<option value="172800">48</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Ορατότητα</label>
|
||||
<select id="market-visibility">
|
||||
<option value="allies">Συμμαχία Μόνο</option>
|
||||
<option value="all">Όλοι</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="amount-group" style="display:none">
|
||||
<label>Amount</label>
|
||||
<input type="number" id="recruit-amount" value="1" min="1" max="9999" style="width:80px;">
|
||||
</div>
|
||||
|
||||
<button id="btn-send" class="btn btn-gold" onclick="window.sendCommand()">Send ⚡</button>
|
||||
</div>
|
||||
|
||||
<div id="build-queue-preview"></div>
|
||||
@@ -233,6 +128,94 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ====== Blueprints Modal ====== -->
|
||||
<div class="modal-overlay" id="blueprints-modal-overlay" onclick="window.closeBlueprintsModal(event)">
|
||||
<div class="custom-modal" id="blueprints-modal" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3>📜 Επιλογή Blueprint</h3>
|
||||
<button class="modal-close" onclick="window.closeBlueprintsModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 15px;">
|
||||
<p style="color:#ccc; font-size:0.85rem; margin-bottom:15px;">Επιλέξτε ένα Blueprint για να αναλάβει το Python την αυτόματη κατασκευή της πόλης.</p>
|
||||
|
||||
<div id="blueprint-list" style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
|
||||
<!-- Currently just one blueprint -->
|
||||
<div class="bld-card" id="bp-card-standard" onclick="window.selectBlueprint('Standard Growth')" style="width:100%; justify-content:flex-start; cursor:pointer;">
|
||||
<span class="bld-icon" style="font-size:2rem;">🏙️</span>
|
||||
<div style="display:flex; flex-direction:column; align-items:flex-start;">
|
||||
<span class="bld-name" style="margin-top:0; font-size:1rem; font-weight:bold;">Standard Growth</span>
|
||||
<span style="font-size:0.75rem; color:#888;">Αυτόματη ανάπτυξη κτιρίων & ακαδημίας</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== Market Modal ====== -->
|
||||
<div class="modal-overlay" id="market-modal-overlay" onclick="window.closeMarketModal(event)">
|
||||
<div class="custom-modal" id="market-modal" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h3>🛒 Ρυθμίσεις Παζαριού</h3>
|
||||
<button class="modal-close" onclick="window.closeMarketModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 15px;">
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Προσφορά</label>
|
||||
<input type="number" id="market-offer-amount" value="1000" min="1" max="99999" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Πόρος Προσφοράς</label>
|
||||
<select id="market-offer-type" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="wood">Ξύλο</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="iron">Ασήμι</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Ζήτηση</label>
|
||||
<input type="number" id="market-demand-amount" value="1000" min="1" max="99999" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Πόρος Ζήτησης</label>
|
||||
<select id="market-demand-type" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="iron">Ασήμι</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="wood">Ξύλο</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; margin-bottom:20px;">
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Χρόνος (Ώρες)</label>
|
||||
<select id="market-max-time" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="1800">0.5</option>
|
||||
<option value="3600">1</option>
|
||||
<option value="7200">2</option>
|
||||
<option value="14400">4</option>
|
||||
<option value="28800">8</option>
|
||||
<option value="43200">12</option>
|
||||
<option value="86400">24</option>
|
||||
<option value="172800">48</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Ορατότητα</label>
|
||||
<select id="market-visibility" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="allies">Συμμαχία Μόνο</option>
|
||||
<option value="all">Όλοι</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-gold" onclick="window.saveMarketModal()" style="width:100%; padding:10px; font-weight:bold;">✅ Αποθήκευση Προσφοράς</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== Building Picker Modal ====== -->
|
||||
<div id="building-modal-overlay" onclick="window.closeBuildingModal(event)">
|
||||
<div id="building-modal">
|
||||
@@ -247,6 +230,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ====== Unit Picker Modal ====== -->
|
||||
<div id="unit-modal-overlay" class="modal-overlay" onclick="window.closeUnitModal(event)">
|
||||
<div id="unit-modal" class="custom-modal">
|
||||
<div class="modal-header">
|
||||
<h3>⚔️ Επιλογή Μονάδας</h3>
|
||||
<button id="unit-modal-close" onclick="window.closeUnitModal()">✕</button>
|
||||
</div>
|
||||
<div id="unit-grid" style="display:flex; flex-wrap:wrap; gap:10px; max-height:70vh; overflow-y:auto; padding:10px 5px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== Academy Picker Modal ====== -->
|
||||
<div id="academy-modal-overlay" onclick="window.closeAcademyModal(event)">
|
||||
<div id="academy-modal">
|
||||
@@ -324,6 +318,7 @@
|
||||
</style>
|
||||
<script>
|
||||
window.PLAYER_ID = "{{ player_id }}";
|
||||
window.WORLD_ID = "{{ world_id }}";
|
||||
</script>
|
||||
<script src="/static/js/state.js?v=6"></script>
|
||||
<script src="/static/js/components/townViewer.js?v=6"></script>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="/player/{{ player_id }}">← Πίσω</a>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}">← Πίσω</a>
|
||||
<h1>🌾 Farm Manager</h1>
|
||||
<span class="online-dot" id="online-dot" title="Κατάσταση Script"></span>
|
||||
<button class="sync-btn" onclick="requestSync()">Live Sync</button>
|
||||
@@ -411,6 +411,7 @@
|
||||
|
||||
<script>
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
let selectedOption = 1;
|
||||
|
||||
// -- Loot option buttons --
|
||||
@@ -445,7 +446,7 @@
|
||||
fetch('/dashboard/farm-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, enabled, loot_option: selectedOption })
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, world_id: WORLD_ID, enabled, loot_option: selectedOption })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
@@ -457,7 +458,7 @@
|
||||
|
||||
// -- Load current settings --
|
||||
function loadSettings() {
|
||||
fetch(`/dashboard/farm-settings?player_id=${PLAYER_ID}`)
|
||||
fetch(`/dashboard/farm-settings?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(cfg => {
|
||||
document.getElementById('farm-enabled').checked = cfg.enabled;
|
||||
@@ -480,7 +481,7 @@
|
||||
}
|
||||
|
||||
function loadFarmData() {
|
||||
fetch(`/dashboard/farm-data?player_id=${PLAYER_ID}`)
|
||||
fetch(`/dashboard/farm-data?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(resp => {
|
||||
const data = resp.towns || [];
|
||||
@@ -563,7 +564,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
town_id: 0,
|
||||
town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
|
||||
type: 'farm_upgrade',
|
||||
payload: { threshold: threshold, action_type: actionType }
|
||||
})
|
||||
@@ -590,7 +591,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
town_id: 0,
|
||||
town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
|
||||
type: 'farm_loot',
|
||||
payload: { loot_option: selectedOption }
|
||||
})
|
||||
@@ -616,7 +617,7 @@
|
||||
fetch('/dashboard/bootcamp-attack-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id: PLAYER_ID })
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, world_id: WORLD_ID })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
@@ -632,7 +633,7 @@
|
||||
|
||||
async function checkWarehouseStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/farm_status?player_id=${PLAYER_ID}`);
|
||||
const res = await fetch(`/api/farm_status?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const data = await res.json();
|
||||
const banner = document.getElementById('warehouse-full-banner');
|
||||
if (banner) banner.style.display = data.warehouse_full ? 'flex' : 'none';
|
||||
@@ -650,7 +651,7 @@
|
||||
}
|
||||
|
||||
function loadBotSettings() {
|
||||
fetch(`/dashboard/bot-settings?player_id=${PLAYER_ID}`)
|
||||
fetch(`/dashboard/bot-settings?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(cfg => {
|
||||
document.getElementById('bootcamp-enabled').checked = !!cfg.bootcamp_enabled;
|
||||
@@ -668,6 +669,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
world_id: WORLD_ID,
|
||||
bootcamp_enabled: document.getElementById('bootcamp-enabled').checked,
|
||||
bootcamp_use_def: document.getElementById('bootcamp-use-def').checked,
|
||||
rural_trade_enabled: document.getElementById('rural-trade-enabled').checked,
|
||||
@@ -707,9 +709,9 @@
|
||||
}
|
||||
|
||||
function loadBotLogs() {
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&feature=bootcamp`)
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&world_id=${WORLD_ID}&feature=bootcamp`)
|
||||
.then(r => r.json()).then(data => renderBotLog('bootcamp-log', data));
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&feature=rural_trade`)
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&world_id=${WORLD_ID}&feature=rural_trade`)
|
||||
.then(r => r.json()).then(data => renderBotLog('rural-trade-log', data));
|
||||
}
|
||||
|
||||
|
||||
@@ -95,9 +95,16 @@
|
||||
.hub-card.farm::before { background: radial-gradient(circle at top left, rgba(74,200,100,0.08), transparent 70%); }
|
||||
.hub-card.farm:hover { border-color: #4acc64; box-shadow: 0 12px 40px rgba(74,200,100,0.15); }
|
||||
|
||||
/* Live Tracker — blue (coming soon, dimmed) */
|
||||
.hub-card.tracker { border-color: #1a2030; opacity: 0.65; cursor: not-allowed; }
|
||||
.hub-card.tracker:hover { transform: none; box-shadow: none; border-color: #1a2030; }
|
||||
/* Live Tracker — teal */
|
||||
.hub-card.tracker { border-color: #1a3035; }
|
||||
.hub-card.tracker::before { background: radial-gradient(circle at top left, rgba(111,207,207,0.08), transparent 70%); }
|
||||
.hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); }
|
||||
|
||||
/* Attack Planner — red/orange */
|
||||
.hub-card.attack { border-color: #301a1a; }
|
||||
.hub-card.attack::before { background: radial-gradient(circle at top left, rgba(224,85,85,0.08), transparent 70%); }
|
||||
.hub-card.attack:hover { border-color: #e05555; box-shadow: 0 12px 40px rgba(224,85,85,0.15); }
|
||||
.hub-card.attack .card-title { color: #e05555; }
|
||||
|
||||
.card-icon {
|
||||
font-size: 2.8rem;
|
||||
@@ -154,24 +161,29 @@
|
||||
|
||||
<div class="hub-grid">
|
||||
|
||||
<a href="/player/{{ player_id }}/admin" class="hub-card admin">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/admin" class="hub-card admin">
|
||||
<span class="card-icon">🏛️</span>
|
||||
<div class="card-title">Admin Mode</div>
|
||||
<div class="card-desc">Κτίρια, στρατολόγηση, αγορά, ουρά κατασκευών και πλήρης έλεγχος πόλεων.</div>
|
||||
</a>
|
||||
|
||||
<a href="/player/{{ player_id }}/farm" class="hub-card farm">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/farm" class="hub-card farm">
|
||||
<span class="card-icon">🌾</span>
|
||||
<div class="card-title">Farm Manager</div>
|
||||
<div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div>
|
||||
</a>
|
||||
|
||||
<div class="hub-card tracker">
|
||||
<span class="soon-badge">Σύντομα</span>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/tracker" class="hub-card tracker">
|
||||
<span class="card-icon">🛡️</span>
|
||||
<div class="card-title">Live Tracker</div>
|
||||
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/attack-planner" class="hub-card attack">
|
||||
<span class="card-icon">⚔️</span>
|
||||
<div class="card-title">Attack Planner</div>
|
||||
<div class="card-desc">Συντονισμένες επιθέσεις σε ακριβή χρόνο. Υπολογισμός χρόνου αποστολής, επιστροφής και πλοίων.</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -180,7 +192,8 @@
|
||||
<script>
|
||||
// Fetch player name to show in the badge
|
||||
const playerId = '{{ player_id }}';
|
||||
fetch(`/dashboard/towns?player_id=${playerId}`)
|
||||
const worldId = '{{ world_id }}';
|
||||
fetch(`/dashboard/towns?player_id=${playerId}&world_id=${worldId}`)
|
||||
.then(r => r.json())
|
||||
.then(towns => {
|
||||
if (towns && towns.length > 0) {
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% for p in players %}
|
||||
<a href="/player/{{ p.player_id }}" class="player-card">
|
||||
<a href="/player/{{ p.player_id }}/{{ p.world_id }}" class="player-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong>{{ p.player }}</strong>
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Παίκτης</th>
|
||||
<th>Κόσμος</th>
|
||||
<th>Κατάσταση</th>
|
||||
<th>Δυνατότητες</th>
|
||||
<th>Προστέθηκε</th>
|
||||
@@ -270,6 +271,13 @@
|
||||
<div class="player-name">{{ m.player_name }}</div>
|
||||
<div class="player-id">ID: {{ m.player_id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if m.world_id and m.world_id != '–' %}
|
||||
<span style="font-size:0.82rem;font-family:monospace;color:#c8a44a;">{{ m.world_id }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.78rem;color:#555;">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.is_online %}
|
||||
<span class="status-online">● Online</span>
|
||||
@@ -279,7 +287,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if clan.owner_id == current_user.id %}
|
||||
<form method="POST" action="/auth/clan/update-features/{{ m.player_id }}" style="display:inline;">
|
||||
<form method="POST" action="/auth/clan/update-features/{{ m.player_id }}/{{ m.world_id }}" style="display:inline;">
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="farm" onchange="this.form.submit()" {{ 'checked' if m.feat_farm }}> 🌾 Farm
|
||||
@@ -287,20 +295,29 @@
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="attack_planner" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner }}> ⚔️ Planner
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="attack_planner_admin" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner_admin }}> 🎯 Planner Admin
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="toggle-group" style="opacity: 0.8;">
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_farm else '#30363d' }}; color: {{ '#3fb950' if m.feat_farm else '#8b949e' }};">🌾 Farm</span>
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_admin else '#8b949e' }};">🏛 Admin</span>
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner else '#8b949e' }};">⚔️ Planner</span>
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner_admin else '#8b949e' }};">🎯 Planner Admin</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td>
|
||||
<td style="text-align:right;">
|
||||
{% if clan.owner_id == current_user.id %}
|
||||
<form method="POST" action="/auth/clan/remove-member/{{ m.player_id }}"
|
||||
onsubmit="return confirm('Αφαίρεση παίκτη {{ m.player_name }}?');">
|
||||
<form method="POST" action="/auth/clan/remove-member/{{ m.player_id }}/{{ m.world_id }}"
|
||||
onsubmit="return confirm('Αφαίρεση {{ m.player_name }} ({{ m.world_id }})?');">
|
||||
<button type="submit" class="btn-danger">Αφαίρεση</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
455
templates/tracker.html
Normal file
455
templates/tracker.html
Normal file
@@ -0,0 +1,455 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Tracker — Grepolis Remote</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--surface: #181824;
|
||||
--border: #2a2a40;
|
||||
--text: #e0e0e0;
|
||||
--muted: #666;
|
||||
--gold: #c8a44a;
|
||||
--red: #e05555;
|
||||
--green: #4acc64;
|
||||
--blue: #6fcfcf;
|
||||
--yellow: #f0c040;
|
||||
--purple: #a07adf;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||
color: var(--text);
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #c8a44a, #f0c96e, #c8a44a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
flex: 1;
|
||||
}
|
||||
.back-link {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
|
||||
/* ---- Connection status pill ---- */
|
||||
#conn-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
#conn-status.live { background: rgba(74,204,100,0.12); border-color: rgba(74,204,100,0.4); color: var(--green); }
|
||||
#conn-status.wait { background: rgba(240,192,64,0.12); border-color: rgba(240,192,64,0.4); color: var(--yellow); }
|
||||
#conn-status.error { background: rgba(224,85,85,0.12); border-color: rgba(224,85,85,0.4); color: var(--red); }
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
|
||||
/* ---- Summary badges ---- */
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.summary-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-width: 130px;
|
||||
}
|
||||
.summary-badge .badge-icon { font-size: 1.2rem; }
|
||||
.summary-badge .badge-count { font-size: 1.5rem; font-weight: 800; margin-left: auto; }
|
||||
.badge-incoming { border-color: rgba(224,85,85,0.4); }
|
||||
.badge-incoming .badge-count { color: var(--red); }
|
||||
.badge-outgoing { border-color: rgba(74,204,100,0.4); }
|
||||
.badge-outgoing .badge-count { color: var(--green); }
|
||||
.badge-support { border-color: rgba(111,207,207,0.4);}
|
||||
.badge-support .badge-count { color: var(--blue); }
|
||||
.badge-other { border-color: rgba(160,122,223,0.4);}
|
||||
.badge-other .badge-count { color: var(--purple); }
|
||||
|
||||
/* ---- Section card ---- */
|
||||
.section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section.incoming { border-color: rgba(224,85,85,0.35); }
|
||||
.section.incoming .section-header { color: var(--red); }
|
||||
.section.outgoing { border-color: rgba(74,204,100,0.35); }
|
||||
.section.outgoing .section-header { color: var(--green); }
|
||||
.section.support { border-color: rgba(111,207,207,0.35); }
|
||||
.section.support .section-header { color: var(--blue); }
|
||||
.section.other { border-color: rgba(160,122,223,0.35); }
|
||||
.section.other .section-header { color: var(--purple); }
|
||||
|
||||
/* ---- Table ---- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 20px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 11px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.025); }
|
||||
|
||||
.type-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.chip-attack_land { background: rgba(224,85,85,0.2); color: var(--red); border: 1px solid rgba(224,85,85,0.4); }
|
||||
.chip-attack_sea { background: rgba(224,85,85,0.2); color: #f08080; border: 1px solid rgba(224,85,85,0.4); }
|
||||
.chip-own_attack_land,
|
||||
.chip-own_attack_sea { background: rgba(74,204,100,0.15); color: var(--green); border: 1px solid rgba(74,204,100,0.4); }
|
||||
.chip-support { background: rgba(111,207,207,0.15);color: var(--blue); border: 1px solid rgba(111,207,207,0.4); }
|
||||
.chip-own_support { background: rgba(111,207,207,0.15);color: #a0e8e8; border: 1px solid rgba(111,207,207,0.4); }
|
||||
.chip-farming { background: rgba(160,122,223,0.15);color: var(--purple); border: 1px solid rgba(160,122,223,0.4); }
|
||||
.chip-espionage { background: rgba(240,192,64,0.15); color: var(--yellow); border: 1px solid rgba(240,192,64,0.4); }
|
||||
.chip-unknown { background: rgba(255,255,255,0.08);color: var(--muted); border: 1px solid var(--border); }
|
||||
|
||||
.countdown {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.countdown.urgent { color: var(--red); animation: pulse 1s infinite; }
|
||||
.countdown.soon { color: var(--yellow); }
|
||||
.countdown.ok { color: var(--green); }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state span { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
|
||||
|
||||
/* ---- Attack flash on new incoming ---- */
|
||||
@keyframes flash-row {
|
||||
0% { background: rgba(224,85,85,0.25); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.flash { animation: flash-row 1.5s ease-out; }
|
||||
|
||||
/* ---- Last updated ---- */
|
||||
#last-updated {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-header">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}" class="back-link">← Πίσω στο Hub</a>
|
||||
<h1>🛡️ Live Tracker — {{ world_id }}</h1>
|
||||
<div id="conn-status" class="wait">
|
||||
<span class="status-dot"></span>
|
||||
<span id="conn-label">Σύνδεση...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-badge badge-incoming">
|
||||
<span class="badge-icon">⚔️</span> Εισερχόμενες
|
||||
<span class="badge-count" id="count-incoming">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-outgoing">
|
||||
<span class="badge-icon">🏹</span> Δικές μου
|
||||
<span class="badge-count" id="count-outgoing">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-support">
|
||||
<span class="badge-icon">🛡️</span> Ενισχύσεις
|
||||
<span class="badge-count" id="count-support">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-other">
|
||||
<span class="badge-icon">🔮</span> Άλλο
|
||||
<span class="badge-count" id="count-other">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="last-updated"></div>
|
||||
|
||||
<!-- Incoming attacks -->
|
||||
<div class="section incoming" id="sec-incoming">
|
||||
<div class="section-header">⚔️ Εισερχόμενες Επιθέσεις</div>
|
||||
<div id="tbl-incoming"><div class="empty-state"><span>✅</span>Δεν υπάρχουν εισερχόμενες επιθέσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Own attacks -->
|
||||
<div class="section outgoing" id="sec-outgoing">
|
||||
<div class="section-header">🏹 Δικές μου Επιθέσεις</div>
|
||||
<div id="tbl-outgoing"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενεργές επιθέσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="section support" id="sec-support">
|
||||
<div class="section-header">🛡️ Ενισχύσεις</div>
|
||||
<div id="tbl-support"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενισχύσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Other (farming, espionage, colonization) -->
|
||||
<div class="section other" id="sec-other">
|
||||
<div class="section-header">🔮 Άλλες Κινήσεις</div>
|
||||
<div id="tbl-other"><div class="empty-state"><span>💤</span>Δεν υπάρχουν άλλες κινήσεις</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
const BASE = '';
|
||||
|
||||
// ---- Classify movement type into a display group ----
|
||||
function classify(type) {
|
||||
const t = (type || '').toLowerCase();
|
||||
if (t === 'attack_land' || t === 'attack_sea') return 'incoming';
|
||||
if (t === 'own_attack_land' || t === 'own_attack_sea') return 'outgoing';
|
||||
if (t === 'support' || t === 'own_support') return 'support';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ---- Format arrival_at (unix seconds) as HH:MM:SS countdown ----
|
||||
function formatCountdown(arrivalAt) {
|
||||
if (!arrivalAt) return { text: '–', cls: 'ok' };
|
||||
const secsLeft = Math.floor(arrivalAt - Date.now() / 1000);
|
||||
if (secsLeft <= 0) return { text: 'Έφτασε', cls: 'ok' };
|
||||
|
||||
const h = Math.floor(secsLeft / 3600);
|
||||
const m = Math.floor((secsLeft % 3600) / 60);
|
||||
const s = secsLeft % 60;
|
||||
const text = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
const cls = secsLeft < 120 ? 'urgent' : secsLeft < 600 ? 'soon' : 'ok';
|
||||
return { text, cls };
|
||||
}
|
||||
|
||||
// ---- Friendly label for type chip ----
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
'attack_land': '⚔️ Χερσαία',
|
||||
'attack_sea': '⚓ Ναυτική',
|
||||
'own_attack_land': '🏹 Επίθεση',
|
||||
'own_attack_sea': '⛵ Ναυτ. Επίθ.',
|
||||
'support': '🛡️ Ενίσχυση',
|
||||
'own_support': '🛡️ Ενίσχυσα',
|
||||
'farming': '🌾 Λεηλασία',
|
||||
'espionage': '🔍 Κατασκοπεία',
|
||||
'colonization': '🏛️ Αποίκιση',
|
||||
'unknown': '❓ Άγνωστο',
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
// ---- Build a table from a list of movements ----
|
||||
function buildTable(movements) {
|
||||
if (!movements.length) return null;
|
||||
let html = `<table>
|
||||
<thead><tr>
|
||||
<th>Τύπος</th>
|
||||
<th>Από</th>
|
||||
<th>Πόλη Αφετηρίας</th>
|
||||
<th>Προς</th>
|
||||
<th>Πόλη Στόχου</th>
|
||||
<th>Άφιξη</th>
|
||||
<th>Αντίστροφη Μέτρηση</th>
|
||||
</tr></thead><tbody>`;
|
||||
for (const m of movements) {
|
||||
const cd = formatCountdown(m.arrival_at);
|
||||
const dt = m.arrival_at
|
||||
? new Date(m.arrival_at * 1000).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '–';
|
||||
html += `<tr data-cmd="${m.command_id}">
|
||||
<td><span class="type-chip chip-${m.cmd_type}">${typeLabel(m.cmd_type)}</span></td>
|
||||
<td>${m.origin_player || '–'}</td>
|
||||
<td>${m.origin_town || '–'}</td>
|
||||
<td>${m.target_player || '–'}</td>
|
||||
<td>${m.target_town || '–'}</td>
|
||||
<td>${dt}</td>
|
||||
<td><span class="countdown ${cd.cls}" data-arrival="${m.arrival_at || 0}">${cd.text}</span></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---- Render all sections from the full movements array ----
|
||||
let _prevIncomingIds = new Set();
|
||||
|
||||
function render(movements) {
|
||||
const groups = { incoming: [], outgoing: [], support: [], other: [] };
|
||||
for (const m of movements) {
|
||||
const g = classify(m.cmd_type);
|
||||
groups[g].push(m);
|
||||
}
|
||||
|
||||
// Sort each group by arrival_at
|
||||
for (const g of Object.values(groups)) {
|
||||
g.sort((a, b) => (a.arrival_at || 0) - (b.arrival_at || 0));
|
||||
}
|
||||
|
||||
// Update summary badges
|
||||
document.getElementById('count-incoming').textContent = groups.incoming.length;
|
||||
document.getElementById('count-outgoing').textContent = groups.outgoing.length;
|
||||
document.getElementById('count-support').textContent = groups.support.length;
|
||||
document.getElementById('count-other').textContent = groups.other.length;
|
||||
|
||||
// Render each table
|
||||
const sections = ['incoming', 'outgoing', 'support', 'other'];
|
||||
const emptyMsgs = {
|
||||
incoming: { icon: '✅', msg: 'Δεν υπάρχουν εισερχόμενες επιθέσεις' },
|
||||
outgoing: { icon: '💤', msg: 'Δεν υπάρχουν ενεργές επιθέσεις' },
|
||||
support: { icon: '💤', msg: 'Δεν υπάρχουν ενισχύσεις' },
|
||||
other: { icon: '💤', msg: 'Δεν υπάρχουν άλλες κινήσεις' },
|
||||
};
|
||||
|
||||
for (const g of sections) {
|
||||
const container = document.getElementById(`tbl-${g}`);
|
||||
const tbl = buildTable(groups[g]);
|
||||
container.innerHTML = tbl || `<div class="empty-state"><span>${emptyMsgs[g].icon}</span>${emptyMsgs[g].msg}</div>`;
|
||||
}
|
||||
|
||||
// Flash rows that are new incoming attacks
|
||||
const newIncomingIds = new Set(groups.incoming.map(m => m.command_id));
|
||||
for (const id of newIncomingIds) {
|
||||
if (!_prevIncomingIds.has(id)) {
|
||||
const row = document.querySelector(`tr[data-cmd="${id}"]`);
|
||||
if (row) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
|
||||
}
|
||||
}
|
||||
_prevIncomingIds = newIncomingIds;
|
||||
|
||||
// Update last-updated timestamp
|
||||
document.getElementById('last-updated').textContent =
|
||||
`Τελευταία ενημέρωση: ${new Date().toLocaleTimeString('el-GR')}`;
|
||||
}
|
||||
|
||||
// ---- Countdown ticker — updates every second client-side ----
|
||||
function tickCountdowns() {
|
||||
document.querySelectorAll('.countdown[data-arrival]').forEach(el => {
|
||||
const arrival = parseInt(el.dataset.arrival, 10);
|
||||
if (!arrival) return;
|
||||
const cd = formatCountdown(arrival);
|
||||
el.textContent = cd.text;
|
||||
el.className = `countdown ${cd.cls}`;
|
||||
});
|
||||
}
|
||||
setInterval(tickCountdowns, 1000);
|
||||
|
||||
// ---- Connection status helpers ----
|
||||
function setStatus(state, label) {
|
||||
const el = document.getElementById('conn-status');
|
||||
el.className = `${state}`;
|
||||
document.getElementById('conn-label').textContent = label;
|
||||
// Re-add the inner dot
|
||||
if (!el.querySelector('.status-dot')) {
|
||||
el.insertAdjacentHTML('afterbegin', '<span class="status-dot"></span>');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1. Initial load ----
|
||||
fetch(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.movements) render(d.movements); })
|
||||
.catch(() => {});
|
||||
|
||||
// ---- 2. SSE stream for real-time updates ----
|
||||
function connectSSE() {
|
||||
setStatus('wait', 'Σύνδεση...');
|
||||
const es = new EventSource(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}/stream`);
|
||||
|
||||
es.onopen = () => setStatus('live', 'Live ●');
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.movements) render(data.movements);
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setStatus('error', 'Αποσυνδέθηκε — επανασύνδεση...');
|
||||
es.close();
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
connectSSE();
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user