MJ: attack coordinator update / various fixes . captcha and back button and jitterloop 2 secs
This commit is contained in:
2
app.py
2
app.py
@@ -6,6 +6,7 @@ from routes.api import api
|
|||||||
from routes.dashboard import dashboard
|
from routes.dashboard import dashboard
|
||||||
from routes.auth import auth
|
from routes.auth import auth
|
||||||
from routes.tracker import tracker
|
from routes.tracker import tracker
|
||||||
|
from routes.attack_planner import attack_planner
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -71,6 +72,7 @@ app.register_blueprint(api)
|
|||||||
app.register_blueprint(dashboard)
|
app.register_blueprint(dashboard)
|
||||||
app.register_blueprint(auth)
|
app.register_blueprint(auth)
|
||||||
app.register_blueprint(tracker)
|
app.register_blueprint(tracker)
|
||||||
|
app.register_blueprint(attack_planner)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
print("✅ Grepolis Remote — DB initialised")
|
print("✅ Grepolis Remote — DB initialised")
|
||||||
|
|||||||
@@ -14,15 +14,16 @@ function randInt(min, max) {
|
|||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedules fn to run after a random ms delay, then reschedules itself
|
// Schedules fn to run after a random ms delay, then reschedules itself.
|
||||||
function jitterLoop(fn, minMs, maxMs) {
|
// Optional initialDelayMs sets the delay for the very first run only.
|
||||||
function schedule() {
|
function jitterLoop(fn, minMs, maxMs, initialDelayMs) {
|
||||||
|
function schedule(delay) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await fn();
|
await fn();
|
||||||
schedule();
|
schedule(randInt(minMs, maxMs));
|
||||||
}, randInt(minMs, maxMs));
|
}, delay);
|
||||||
}
|
}
|
||||||
schedule();
|
schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
|
|||||||
@@ -193,7 +193,27 @@ function gatherState() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { player, player_id, alliance_id, total_points, world_id: world, towns: townList };
|
// ---- World speed & unit speed table (for attack planner calculations) -----
|
||||||
|
let world_speed = 1;
|
||||||
|
let unit_speeds = {};
|
||||||
|
try {
|
||||||
|
world_speed = uw.Game?.world_speed || 1;
|
||||||
|
const gdUnits = uw.GameData?.units || {};
|
||||||
|
for (const [uid, ud] of Object.entries(gdUnits)) {
|
||||||
|
if (ud.speed !== undefined) {
|
||||||
|
unit_speeds[uid] = {
|
||||||
|
speed: ud.speed || 0,
|
||||||
|
population: ud.population || 1,
|
||||||
|
is_naval: !!(ud.naval || ud.is_naval || false),
|
||||||
|
capacity: ud.capacity || 0, // transport ship cargo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { log(`unit speed gather failed: ${e}`); }
|
||||||
|
|
||||||
|
return { player, player_id, alliance_id, total_points, world_id: world,
|
||||||
|
world_speed, unit_speeds, towns: townList };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushState() {
|
function pushState() {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ let captchaActive = false;
|
|||||||
|
|
||||||
function reportCaptcha(detected) {
|
function reportCaptcha(detected) {
|
||||||
const player_id = uw.Game?.player_id;
|
const player_id = uw.Game?.player_id;
|
||||||
|
const world_id = uw.Game?.world_id || '';
|
||||||
if (!player_id) return;
|
if (!player_id) return;
|
||||||
apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}`, {
|
apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}&world_id=${world_id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ detected })
|
body: JSON.stringify({ detected })
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ function boot() {
|
|||||||
if (typeof window._grcInitTracker === 'function') {
|
if (typeof window._grcInitTracker === 'function') {
|
||||||
window._grcInitTracker(); // live tracker event-driven
|
window._grcInitTracker(); // live tracker event-driven
|
||||||
}
|
}
|
||||||
|
if (typeof window._grcInitAttackPlanner === 'function') {
|
||||||
|
window._grcInitAttackPlanner(); // attack planner countdown engine
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'complete') {
|
if (document.readyState === 'complete') {
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
|
})();
|
||||||
48
db.py
48
db.py
@@ -123,7 +123,55 @@ def init_db():
|
|||||||
''')
|
''')
|
||||||
c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)')
|
c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)')
|
||||||
|
|
||||||
|
# Attack Plans — coordinated timed strikes across multiple players/towns
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS attack_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
world_id TEXT NOT NULL,
|
||||||
|
plan_name TEXT NOT NULL,
|
||||||
|
created_by_player_id TEXT NOT NULL,
|
||||||
|
target_town_id TEXT,
|
||||||
|
target_town_name TEXT,
|
||||||
|
target_x REAL,
|
||||||
|
target_y REAL,
|
||||||
|
target_arrival_time INTEGER NOT NULL, -- unix epoch (UTC)
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
-- draft | active | completed | cancelled
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
c.execute('CREATE INDEX IF NOT EXISTS idx_attack_plans_world ON attack_plans(world_id, status)')
|
||||||
|
|
||||||
|
# Attack Plan Participants — one row per attacking town per plan
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS attack_plan_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES attack_plans(id) ON DELETE CASCADE,
|
||||||
|
player_id TEXT NOT NULL,
|
||||||
|
world_id TEXT NOT NULL,
|
||||||
|
origin_town_id TEXT NOT NULL,
|
||||||
|
origin_town_name TEXT,
|
||||||
|
units TEXT NOT NULL DEFAULT '{}', -- JSON
|
||||||
|
attack_type TEXT, -- 'attack_land' | 'attack_sea'
|
||||||
|
transport_needed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transport_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
travel_time_secs INTEGER,
|
||||||
|
send_time INTEGER, -- unix epoch (UTC), calculated
|
||||||
|
return_time INTEGER, -- unix epoch (UTC), calculated
|
||||||
|
is_feasible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
error_msg TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending | armed | sent | missed | cancelled
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(plan_id, origin_town_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
c.execute('CREATE INDEX IF NOT EXISTS idx_app_plan ON attack_plan_participants(plan_id)')
|
||||||
|
c.execute('CREATE INDEX IF NOT EXISTS idx_app_player ON attack_plan_participants(player_id, world_id)')
|
||||||
|
|
||||||
# Migration: add new columns if upgrading an existing database
|
# Migration: add new columns if upgrading an existing database
|
||||||
|
|
||||||
for _col in [
|
for _col in [
|
||||||
'ALTER TABLE town_state ADD COLUMN player_id TEXT',
|
'ALTER TABLE town_state ADD COLUMN player_id TEXT',
|
||||||
'ALTER TABLE town_state ADD COLUMN alliance_id TEXT',
|
'ALTER TABLE town_state ADD COLUMN alliance_id TEXT',
|
||||||
|
|||||||
@@ -98,17 +98,30 @@ def receive_state():
|
|||||||
datetime.utcnow().isoformat()
|
datetime.utcnow().isoformat()
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Store world speed + unit data for attack planner calculations
|
||||||
|
world_speed = data.get('world_speed')
|
||||||
|
unit_speeds = data.get('unit_speeds')
|
||||||
|
if world_id and world_speed is not None and unit_speeds:
|
||||||
|
world_data = json.dumps({'world_speed': world_speed, 'unit_speeds': unit_speeds})
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO kv_store (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
''', (f'world_data_{world_id}', world_data, datetime.utcnow().isoformat()))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
evaluate_blueprints(conn)
|
evaluate_blueprints(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error evaluating blueprints:", e)
|
print("Error evaluating blueprints:", e)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'ok': True, 'towns_updated': len(towns)})
|
return jsonify({'ok': True, 'towns_updated': len(towns)})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/commands/pending
|
# GET /api/commands/pending
|
||||||
# Tampermonkey polls this to get the next command to execute.
|
# Tampermonkey polls this to get the next command to execute.
|
||||||
@@ -390,12 +403,14 @@ def command_result(cmd_id):
|
|||||||
@api.route('/api/captcha/alert', methods=['POST'])
|
@api.route('/api/captcha/alert', methods=['POST'])
|
||||||
def captcha_alert():
|
def captcha_alert():
|
||||||
player_id = request.args.get('player_id')
|
player_id = request.args.get('player_id')
|
||||||
|
world_id = request.args.get('world_id', '').strip()
|
||||||
if not player_id:
|
if not player_id:
|
||||||
return jsonify({'error': 'no player_id provided'}), 400
|
return jsonify({'error': 'no player_id provided'}), 400
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
detected = bool(data.get('detected', False))
|
detected = bool(data.get('detected', False))
|
||||||
kv_key = f'captcha_active_{player_id}'
|
# Key is world-specific so captcha in world A doesn't affect world B
|
||||||
|
kv_key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
@@ -409,6 +424,7 @@ def captcha_alert():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /api/market_data
|
# POST /api/market_data
|
||||||
# Tampermonkey uploads the market scan data.
|
# Tampermonkey uploads the market scan data.
|
||||||
|
|||||||
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)
|
||||||
@@ -147,13 +147,15 @@ def options():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
members.append({
|
members.append({
|
||||||
'id': row['id'],
|
'id': row['id'],
|
||||||
'player_id': row['player_id'],
|
'player_id': row['player_id'],
|
||||||
'player_name': row['player_name'] or 'Άγνωστος',
|
'player_name': row['player_name'] or 'Άγνωστος',
|
||||||
'joined_at': row['joined_at'][:10] if row['joined_at'] else '',
|
'joined_at': row['joined_at'][:10] if row['joined_at'] else '',
|
||||||
'is_online': is_online,
|
'is_online': is_online,
|
||||||
'feat_farm': 'farm' in (row['features'] or 'farm,admin'),
|
'feat_farm': 'farm' in (row['features'] or 'farm,admin'),
|
||||||
'feat_admin': 'admin' in (row['features'] or 'farm,admin'),
|
'feat_admin': 'admin' in (row['features'] or 'farm,admin'),
|
||||||
|
'feat_atk_planner': 'attack_planner' in (row['features'] or ''),
|
||||||
|
'feat_atk_planner_admin': 'attack_planner_admin' in (row['features'] or ''),
|
||||||
})
|
})
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -232,9 +234,12 @@ def remove_member(player_id):
|
|||||||
@auth.route('/auth/clan/update-features/<player_id>', methods=['POST'])
|
@auth.route('/auth/clan/update-features/<player_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def update_member_features(player_id):
|
def update_member_features(player_id):
|
||||||
farm = 'farm' if request.form.get('farm') else None
|
farm = 'farm' if request.form.get('farm') else None
|
||||||
admin = 'admin' if request.form.get('admin') else None
|
admin = 'admin' if request.form.get('admin') else None
|
||||||
features = ','.join(f for f in [farm, admin] if f) or ''
|
atk_planner = 'attack_planner' if request.form.get('attack_planner') else None
|
||||||
|
atk_planner_admin = 'attack_planner_admin' if request.form.get('attack_planner_admin') else None
|
||||||
|
|
||||||
|
features = ','.join(f for f in [farm, admin, atk_planner, atk_planner_admin] if f) or ''
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
clan = conn.execute(
|
clan = conn.execute(
|
||||||
@@ -250,6 +255,7 @@ def update_member_features(player_id):
|
|||||||
return redirect(url_for('auth.options'))
|
return redirect(url_for('auth.options'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /auth/clan/add-admin
|
# POST /auth/clan/add-admin
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -35,7 +35,17 @@ def index():
|
|||||||
''', (clan_id,)).fetchall()
|
''', (clan_id,)).fetchall()
|
||||||
|
|
||||||
captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").fetchall()
|
captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").fetchall()
|
||||||
active_captchas = {r['key'].replace('captcha_active_', ''): True for r in captcha_rows if r['value'] == '1'}
|
# Key format is captcha_active_{player_id}_{world_id} — build a (player_id, world_id) → bool map
|
||||||
|
active_captchas = {}
|
||||||
|
for r in captcha_rows:
|
||||||
|
if r['value'] != '1':
|
||||||
|
continue
|
||||||
|
parts = r['key'].replace('captcha_active_', '').split('_', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
active_captchas[(parts[0], parts[1])] = True
|
||||||
|
else:
|
||||||
|
# Legacy key (player_id only) — keep working
|
||||||
|
active_captchas[(parts[0], '')] = True
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
@@ -50,14 +60,20 @@ def index():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
wid = r['world_id'] or ''
|
||||||
|
captcha_active = (
|
||||||
|
active_captchas.get((r['player_id'], wid), False) or
|
||||||
|
active_captchas.get((r['player_id'], ''), False) # legacy fallback
|
||||||
|
)
|
||||||
players.append({
|
players.append({
|
||||||
'player': r['player'],
|
'player': r['player'],
|
||||||
'player_id': r['player_id'],
|
'player_id': r['player_id'],
|
||||||
'world_id': r['world_id'] or 'Unknown',
|
'world_id': wid or 'Unknown',
|
||||||
'is_online': is_online,
|
'is_online': is_online,
|
||||||
'captcha_active': active_captchas.get(r['player_id'], False)
|
'captcha_active': captcha_active
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return render_template('index.html', players=players, no_clan=False)
|
return render_template('index.html', players=players, no_clan=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +97,11 @@ def player_farm(player_id, world_id):
|
|||||||
def player_tracker(player_id, world_id):
|
def player_tracker(player_id, world_id):
|
||||||
return render_template('tracker.html', player_id=player_id, world_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):
|
||||||
|
return render_template('attack_planner.html', player_id=player_id, world_id=world_id)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /dashboard/farm-settings — returns current farm config
|
# GET /dashboard/farm-settings — returns current farm config
|
||||||
@@ -330,16 +351,26 @@ def client_status():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
||||||
def captcha_status():
|
def captcha_status():
|
||||||
player_id = request.args.get('player_id')
|
player_id = request.args.get('player_id', '').strip()
|
||||||
|
world_id = request.args.get('world_id', '').strip()
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
# Try world-specific key first, fall back to legacy player-only key
|
||||||
|
key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', )
|
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if not row and world_id:
|
||||||
|
# Legacy fallback
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM kv_store WHERE key = ?",
|
||||||
|
(f'captcha_active_{player_id}',)
|
||||||
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
active = bool(row and row['value'] == '1')
|
active = bool(row and row['value'] == '1')
|
||||||
return jsonify({'captcha_active': active})
|
return jsonify({'captcha_active': active})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /dashboard/commands/queue
|
# GET /dashboard/commands/queue
|
||||||
# Returns pending+executing BUILD commands for a specific town,
|
# Returns pending+executing BUILD commands for a specific town,
|
||||||
|
|||||||
@@ -245,18 +245,18 @@ window.cancelCommand = async function(id) {
|
|||||||
|
|
||||||
window.fetchCaptchaStatus = async function() {
|
window.fetchCaptchaStatus = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dashboard/captcha-status?player_id=' + window.PLAYER_ID);
|
const res = await fetch(
|
||||||
|
`/dashboard/captcha-status?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`
|
||||||
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const banner = document.getElementById('captcha-banner');
|
const banner = document.getElementById('captcha-banner');
|
||||||
if (!banner) return;
|
if (!banner) return;
|
||||||
|
|
||||||
if (data.captcha_active) {
|
if (data.captcha_active) {
|
||||||
// Only show it if the user hasn't explicitly clicked 'close' for this specific alert
|
|
||||||
if (banner.dataset.dismissed !== '1') {
|
if (banner.dataset.dismissed !== '1') {
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Captcha cleared from the game - hide banner and reset dismiss state for next time
|
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
banner.dataset.dismissed = '0';
|
banner.dataset.dismissed = '0';
|
||||||
}
|
}
|
||||||
|
|||||||
421
templates/attack_planner.html
Normal file
421
templates/attack_planner.html
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
<!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;--surface:#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}
|
||||||
|
.page-header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;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:.85rem;transition:color .2s}
|
||||||
|
.back-link:hover{color:var(--text)}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start}
|
||||||
|
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||||||
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:1.5rem;margin-bottom:1rem}
|
||||||
|
.card-title{font-size:1rem;font-weight:700;color:var(--gold);margin-bottom:1rem;padding-bottom:.75rem;border-bottom:1px solid var(--border)}
|
||||||
|
label{display:block;font-size:.8rem;color:var(--muted);margin-bottom:.3rem;margin-top:.75rem}
|
||||||
|
label:first-of-type{margin-top:0}
|
||||||
|
input[type=text],input[type=number],input[type=datetime-local]{
|
||||||
|
width:100%;padding:9px 12px;background:#0f0f1a;border:1px solid var(--border);
|
||||||
|
border-radius:8px;color:var(--text);font-size:.875rem;font-family:inherit;transition:border-color .2s}
|
||||||
|
input:focus{outline:none;border-color:var(--gold)}
|
||||||
|
.btn{padding:9px 18px;border:none;border-radius:8px;font-family:inherit;font-weight:600;
|
||||||
|
font-size:.85rem;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-sm{padding:5px 12px;font-size:.78rem}
|
||||||
|
.mt{margin-top:.75rem}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:.83rem}
|
||||||
|
th{padding:8px 12px;text-align:left;color:var(--muted);font-size:.72rem;text-transform:uppercase;
|
||||||
|
letter-spacing:.05em;border-bottom:1px solid var(--border)}
|
||||||
|
td{padding:9px 12px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
|
||||||
|
tr:last-child td{border-bottom:none}
|
||||||
|
tr:hover td{background:rgba(255,255,255,.02)}
|
||||||
|
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:700}
|
||||||
|
.badge-draft{background:rgba(102,102,102,.2);color:#999;border:1px solid #444}
|
||||||
|
.badge-active{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.35)}
|
||||||
|
.badge-completed{background:rgba(111,207,207,.15);color:var(--blue);border:1px solid rgba(111,207,207,.35)}
|
||||||
|
.badge-cancelled{background:rgba(224,85,85,.1);color:var(--red);border:1px solid rgba(224,85,85,.2)}
|
||||||
|
.badge-pending{background:rgba(240,192,64,.12);color:var(--yellow);border:1px solid rgba(240,192,64,.3)}
|
||||||
|
.badge-armed{background:rgba(224,120,48,.15);color:var(--orange);border:1px solid rgba(224,120,48,.35)}
|
||||||
|
.badge-sent{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.35)}
|
||||||
|
.badge-missed{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.35)}
|
||||||
|
.empty{text-align:center;padding:2rem;color:var(--muted);font-size:.875rem}
|
||||||
|
.error-box{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--red);
|
||||||
|
padding:10px 14px;border-radius:8px;font-size:.83rem;margin-top:.75rem}
|
||||||
|
.info-box{background:rgba(111,207,207,.08);border:1px solid rgba(111,207,207,.25);color:var(--blue);
|
||||||
|
padding:10px 14px;border-radius:8px;font-size:.83rem;margin-top:.75rem}
|
||||||
|
.warn-box{background:rgba(240,192,64,.08);border:1px solid rgba(240,192,64,.25);color:var(--yellow);
|
||||||
|
padding:10px 14px;border-radius:8px;font-size:.83rem;margin-top:.5rem}
|
||||||
|
#msg{display:none;margin-top:.75rem}
|
||||||
|
.countdown{font-family:monospace;font-weight:700;color:var(--yellow)}
|
||||||
|
.section-sep{height:1px;background:var(--border);margin:1.5rem 0}
|
||||||
|
.plan-row{cursor:pointer}.plan-row:hover td{background:rgba(200,164,74,.05)}
|
||||||
|
.detail-panel{display:none}
|
||||||
|
.detail-panel.open{display:block}
|
||||||
|
.unit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:.5rem;margin-top:.5rem}
|
||||||
|
.unit-input{display:flex;flex-direction:column;gap:3px}
|
||||||
|
.unit-input label{font-size:.72rem;color:var(--muted);margin:0}
|
||||||
|
.unit-input input{padding:6px 8px;font-size:.82rem}
|
||||||
|
.feasible-ok{color:var(--green);font-size:.78rem}
|
||||||
|
.feasible-err{color:var(--red);font-size:.78rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/player/{{ player_id }}/{{ world_id }}/hub" class="back-link">← Hub</a>
|
||||||
|
<h1>⚔️ Attack Planner — {{ world_id }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- LEFT: Plans list -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">📋 Ενεργά Πλάνα</div>
|
||||||
|
<div id="plans-list"><div class="empty">Φόρτωση...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan detail panel (shown when a plan is clicked) -->
|
||||||
|
<div class="card detail-panel" id="detail-panel">
|
||||||
|
<div class="card-title" id="detail-title">Λεπτομέρειες Πλάνου</div>
|
||||||
|
<div id="detail-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Create plan + add participant -->
|
||||||
|
<div>
|
||||||
|
<!-- Create plan -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">➕ Νέο Πλάνο Επίθεσης</div>
|
||||||
|
|
||||||
|
<label>Όνομα Πλάνου</label>
|
||||||
|
<input type="text" id="plan-name" placeholder="π.χ. Επίθεση στον Leonidas">
|
||||||
|
|
||||||
|
<label>Όνομα Στόχου</label>
|
||||||
|
<input type="text" id="target-name" placeholder="π.χ. Sparta Colony">
|
||||||
|
|
||||||
|
<label>Συντεταγμένες Στόχου (X)</label>
|
||||||
|
<input type="number" id="target-x" placeholder="π.χ. 394" min="0" max="999">
|
||||||
|
|
||||||
|
<label>Συντεταγμένες Στόχου (Y)</label>
|
||||||
|
<input type="number" id="target-y" placeholder="π.χ. 512" min="0" max="999">
|
||||||
|
|
||||||
|
<label>Ώρα Άφιξης (τοπική ώρα)</label>
|
||||||
|
<input type="datetime-local" id="arrival-time">
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
⏱ Η ώρα άφιξης πρέπει να είναι τουλάχιστον 2 λεπτά στο μέλλον. Όλες οι ώρες αποθηκεύονται σε UTC.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt">
|
||||||
|
<button class="btn btn-gold" onclick="createPlan()">Δημιουργία Πλάνου →</button>
|
||||||
|
</div>
|
||||||
|
<div id="msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add participant (shown after plan selected) -->
|
||||||
|
<div class="card" id="add-participant-card" style="display:none">
|
||||||
|
<div class="card-title">👤 Προσθήκη Συμμετέχοντα</div>
|
||||||
|
<p style="font-size:.82rem;color:var(--muted);margin-bottom:.75rem">
|
||||||
|
Πλάνο: <strong id="selected-plan-name" style="color:var(--gold)"></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>Origin Town ID</label>
|
||||||
|
<input type="text" id="p-town-id" placeholder="Από town_state">
|
||||||
|
|
||||||
|
<label>Όνομα Πόλης</label>
|
||||||
|
<input type="text" id="p-town-name" placeholder="Προαιρετικό">
|
||||||
|
|
||||||
|
<label>Συντεταγμένες Πόλης X</label>
|
||||||
|
<input type="number" id="p-x" min="0" max="999">
|
||||||
|
|
||||||
|
<label>Συντεταγμένες Πόλης Y</label>
|
||||||
|
<input type="number" id="p-y" min="0" max="999">
|
||||||
|
|
||||||
|
<label>Θαλάσσια Ζώνη (sea)</label>
|
||||||
|
<input type="number" id="p-sea" placeholder="π.χ. 45">
|
||||||
|
|
||||||
|
<div class="section-sep"></div>
|
||||||
|
<div class="card-title" style="border:none;padding:0;margin-bottom:.5rem">🗡 Μονάδες</div>
|
||||||
|
<div class="unit-grid" id="unit-inputs"></div>
|
||||||
|
|
||||||
|
<div class="mt">
|
||||||
|
<button class="btn btn-gold" onclick="addParticipant()">Υπολογισμός & Προσθήκη</button>
|
||||||
|
</div>
|
||||||
|
<div id="participant-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const PLAYER_ID = '{{ player_id }}';
|
||||||
|
const WORLD_ID = '{{ world_id }}';
|
||||||
|
let selectedPlanId = null;
|
||||||
|
|
||||||
|
// ---- Unit list for inputs (common Grepolis land units) ----
|
||||||
|
const UNITS = [
|
||||||
|
'swordsman','slinger','archer','hoplite','horseman',
|
||||||
|
'chariot','catapult','godsent',
|
||||||
|
'bireme','attack_ship','demolition_ship','transport_ship','colonize_ship'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build unit input grid
|
||||||
|
const grid = document.getElementById('unit-inputs');
|
||||||
|
UNITS.forEach(u => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'unit-input';
|
||||||
|
div.innerHTML = `<label>${u}</label><input type="number" id="unit_${u}" min="0" value="0" placeholder="0">`;
|
||||||
|
grid.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
function showMsg(el, text, isError) {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.className = isError ? 'error-box' : 'info-box';
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(s) {
|
||||||
|
return `<span class="badge badge-${s}">${s}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(unix) {
|
||||||
|
if (!unix) return '–';
|
||||||
|
return new Date(unix * 1000).toLocaleString('el-GR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countdown(unix) {
|
||||||
|
if (!unix) return '–';
|
||||||
|
const s = Math.max(0, Math.floor(unix - Date.now()/1000));
|
||||||
|
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||||
|
return `<span class="countdown">${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Load plans ----
|
||||||
|
async function loadPlans() {
|
||||||
|
const res = await fetch(`/api/${WORLD_ID}/attack_plans`);
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('plans-list');
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
el.innerHTML = '<div class="empty">Δεν υπάρχουν πλάνα ακόμη.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<table>
|
||||||
|
<thead><tr><th>Πλάνο</th><th>Στόχος</th><th>Άφιξη</th><th>Κατάσταση</th><th>Συμμ.</th><th></th></tr></thead>
|
||||||
|
<tbody>`;
|
||||||
|
for (const p of data) {
|
||||||
|
html += `<tr class="plan-row" onclick="selectPlan(${p.id},'${p.plan_name}')">
|
||||||
|
<td><strong>${p.plan_name}</strong></td>
|
||||||
|
<td>${p.target_town_name || '–'}</td>
|
||||||
|
<td style="font-size:.78rem">${formatTs(p.target_arrival_time)}</td>
|
||||||
|
<td>${statusBadge(p.status)}</td>
|
||||||
|
<td>${p.participant_count}</td>
|
||||||
|
<td>
|
||||||
|
${p.status==='draft' ? `<button class="btn btn-green btn-sm" onclick="event.stopPropagation();armPlan(${p.id})">ARM</button>` : ''}
|
||||||
|
<button class="btn btn-red btn-sm" onclick="event.stopPropagation();cancelPlan(${p.id})" style="margin-left:4px">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Select plan → show detail + add participant panel ----
|
||||||
|
window.selectPlan = async function(planId, planName) {
|
||||||
|
selectedPlanId = planId;
|
||||||
|
document.getElementById('selected-plan-name').textContent = planName;
|
||||||
|
document.getElementById('add-participant-card').style.display = 'block';
|
||||||
|
await loadPlanDetail(planId);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadPlanDetail(planId) {
|
||||||
|
const res = await fetch(`/api/${WORLD_ID}/attack_plans/${planId}`);
|
||||||
|
const plan = await res.json();
|
||||||
|
const panel = document.getElementById('detail-panel');
|
||||||
|
const body = document.getElementById('detail-body');
|
||||||
|
document.getElementById('detail-title').textContent =
|
||||||
|
`📌 ${plan.plan_name} — ${plan.target_town_name || 'Άγνωστος Στόχος'}`;
|
||||||
|
|
||||||
|
if (!plan.participants || !plan.participants.length) {
|
||||||
|
body.innerHTML = '<div class="empty">Χωρίς συμμετέχοντες ακόμη.</div>';
|
||||||
|
panel.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Πόλη</th><th>Τύπος</th><th>Πλοία</th>
|
||||||
|
<th>Αποστολή</th><th>Επιστροφή</th><th>Κατάσταση</th><th>Feasible</th><th></th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
for (const p of plan.participants) {
|
||||||
|
const feasHtml = p.is_feasible
|
||||||
|
? '<span class="feasible-ok">✅</span>'
|
||||||
|
: `<span class="feasible-err" title="${p.error_msg}">❌</span>`;
|
||||||
|
html += `<tr>
|
||||||
|
<td><strong>${p.origin_town_name||p.origin_town_id}</strong></td>
|
||||||
|
<td>${p.attack_type||'–'}</td>
|
||||||
|
<td>${p.transport_needed ? p.transport_count : '–'}</td>
|
||||||
|
<td style="font-size:.78rem">${formatTs(p.send_time)}</td>
|
||||||
|
<td style="font-size:.78rem">${formatTs(p.return_time)}</td>
|
||||||
|
<td>${statusBadge(p.status)}</td>
|
||||||
|
<td>${feasHtml}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-red btn-sm"
|
||||||
|
onclick="removeParticipant(${planId},'${p.origin_town_id}')">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
// Return time summary
|
||||||
|
const latest = plan.participants.reduce((mx, p) => Math.max(mx, p.return_time||0), 0);
|
||||||
|
if (latest) {
|
||||||
|
html += `<div class="info-box" style="margin-top:.75rem">
|
||||||
|
🏠 Τελευταία επιστροφή στρατού: <strong>${formatTs(latest)}</strong>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
panel.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Create plan ----
|
||||||
|
window.createPlan = async function() {
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
const name = document.getElementById('plan-name').value.trim();
|
||||||
|
const tName = document.getElementById('target-name').value.trim();
|
||||||
|
const tx = parseFloat(document.getElementById('target-x').value);
|
||||||
|
const ty = parseFloat(document.getElementById('target-y').value);
|
||||||
|
const dtLocal = document.getElementById('arrival-time').value;
|
||||||
|
|
||||||
|
if (!dtLocal) { showMsg(msg,'Επίλεξε ώρα άφιξης',true); return; }
|
||||||
|
|
||||||
|
const arrivalUnix = Math.floor(new Date(dtLocal).getTime() / 1000);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/${WORLD_ID}/attack_plans`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
'X-Clan-Key': window.__GRC_CLAN_KEY || '' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
player_id: PLAYER_ID, plan_name: name||'Επίθεση',
|
||||||
|
target_town_name: tName, target_x: tx||null, target_y: ty||null,
|
||||||
|
target_arrival_time: arrivalUnix
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
showMsg(msg, `✅ Πλάνο δημιουργήθηκε (ID: ${data.plan_id})`, false);
|
||||||
|
loadPlans();
|
||||||
|
} else {
|
||||||
|
showMsg(msg, `❌ ${data.error}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Add participant ----
|
||||||
|
window.addParticipant = async function() {
|
||||||
|
if (!selectedPlanId) return;
|
||||||
|
const result = document.getElementById('participant-result');
|
||||||
|
|
||||||
|
const units = {};
|
||||||
|
UNITS.forEach(u => {
|
||||||
|
const v = parseInt(document.getElementById(`unit_${u}`).value) || 0;
|
||||||
|
if (v > 0) units[u] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
requester_player_id: PLAYER_ID,
|
||||||
|
player_id: document.getElementById('p-town-id').value.split('_')[0] || PLAYER_ID,
|
||||||
|
origin_town_id: document.getElementById('p-town-id').value.trim(),
|
||||||
|
origin_town_name:document.getElementById('p-town-name').value.trim(),
|
||||||
|
origin_x: parseFloat(document.getElementById('p-x').value)||null,
|
||||||
|
origin_y: parseFloat(document.getElementById('p-y').value)||null,
|
||||||
|
origin_sea: parseInt(document.getElementById('p-sea').value)||null,
|
||||||
|
units
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`/api/${WORLD_ID}/attack_plans/${selectedPlanId}/participants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
'X-Clan-Key': window.__GRC_CLAN_KEY || '' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.ok || data.is_feasible !== undefined) {
|
||||||
|
let html = data.is_feasible
|
||||||
|
? `<div class="info-box">✅ Feasible — Travel: ${Math.floor(data.travel_time_secs/60)}m, Ships: ${data.transport_count||0}, Send: ${new Date(data.send_time*1000).toLocaleString('el-GR')}</div>`
|
||||||
|
: `<div class="error-box">❌ ${data.error_msg}</div>`;
|
||||||
|
result.innerHTML = html;
|
||||||
|
loadPlanDetail(selectedPlanId);
|
||||||
|
} else {
|
||||||
|
result.innerHTML = `<div class="error-box">❌ ${data.error||'Unknown error'}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Remove participant ----
|
||||||
|
window.removeParticipant = async function(planId, townId) {
|
||||||
|
if (!confirm('Αφαίρεση συμμετέχοντα;')) return;
|
||||||
|
await fetch(`/api/${WORLD_ID}/attack_plans/${planId}/participants/${townId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
'X-Clan-Key': window.__GRC_CLAN_KEY || '' },
|
||||||
|
body: JSON.stringify({ requester_player_id: PLAYER_ID })
|
||||||
|
});
|
||||||
|
loadPlanDetail(planId);
|
||||||
|
loadPlans();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Arm plan ----
|
||||||
|
window.armPlan = async function(planId) {
|
||||||
|
const res = await fetch(`/api/${WORLD_ID}/attack_plans/${planId}/arm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
'X-Clan-Key': window.__GRC_CLAN_KEY || '' },
|
||||||
|
body: JSON.stringify({ player_id: PLAYER_ID })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.ok ? '✅ Πλάνο ενεργοποιήθηκε!' : `❌ ${data.error}`);
|
||||||
|
loadPlans();
|
||||||
|
if (selectedPlanId === planId) loadPlanDetail(planId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Cancel plan ----
|
||||||
|
window.cancelPlan = async function(planId) {
|
||||||
|
if (!confirm('Ακύρωση πλάνου;')) return;
|
||||||
|
await fetch(`/api/${WORLD_ID}/attack_plans/${planId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
'X-Clan-Key': window.__GRC_CLAN_KEY || '' },
|
||||||
|
body: JSON.stringify({ player_id: PLAYER_ID })
|
||||||
|
});
|
||||||
|
loadPlans();
|
||||||
|
if (selectedPlanId === planId) {
|
||||||
|
document.getElementById('detail-panel').classList.remove('open');
|
||||||
|
document.getElementById('add-participant-card').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Auto-refresh every 15s ----
|
||||||
|
loadPlans();
|
||||||
|
setInterval(loadPlans, 15000);
|
||||||
|
setInterval(() => { if (selectedPlanId) loadPlanDetail(selectedPlanId); }, 15000);
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1><a href="/" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Back to Players">⬅️</a> ⚔️ Grepolis Remote</h1>
|
<h1><a href="/player/{{ player_id }}/{{ world_id }}" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Πίσω στο Hub">⬅️</a> ⚔️ Grepolis Remote</h1>
|
||||||
<div class="status-indicator" style="display: flex; align-items: center; gap: 10px;">
|
<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>
|
<button class="btn btn-gold btn-sm" id="live-btn" onclick="window.requestLiveSync()" title="Request immediate data update from game" style="padding: 4px 8px; font-size: 0.72rem; border-radius: 4px; border: 1px solid #c8a44a;">⚡ Live Sync</button>
|
||||||
<div id="server-status" class="conn-badge">Server…</div>
|
<div id="server-status" class="conn-badge">Server…</div>
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
.hub-card.tracker::before { background: radial-gradient(circle at top left, rgba(111,207,207,0.08), transparent 70%); }
|
.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); }
|
.hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); }
|
||||||
|
|
||||||
|
/* Attack Planner — red/orange */
|
||||||
|
.hub-card.attack { border-color: #301a1a; }
|
||||||
|
.hub-card.attack::before { background: radial-gradient(circle at top left, rgba(224,85,85,0.08), transparent 70%); }
|
||||||
|
.hub-card.attack:hover { border-color: #e05555; box-shadow: 0 12px 40px rgba(224,85,85,0.15); }
|
||||||
|
.hub-card.attack .card-title { color: #e05555; }
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
font-size: 2.8rem;
|
font-size: 2.8rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -173,6 +179,12 @@
|
|||||||
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
|
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/player/{{ player_id }}/{{ world_id }}/attack-planner" class="hub-card attack">
|
||||||
|
<span class="card-icon">⚔️</span>
|
||||||
|
<div class="card-title">Attack Planner</div>
|
||||||
|
<div class="card-desc">Συντονισμένες επιθέσεις σε ακριβή χρόνο. Υπολογισμός χρόνου αποστολής, επιστροφής και πλοίων.</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/" class="back-link">← Επιστροφή στην επιλογή παίκτη</a>
|
<a href="/" class="back-link">← Επιστροφή στην επιλογή παίκτη</a>
|
||||||
|
|||||||
@@ -287,14 +287,23 @@
|
|||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
|
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
|
||||||
</label>
|
</label>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" name="attack_planner" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner }}> ⚔️ Planner
|
||||||
|
</label>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" name="attack_planner_admin" onchange="this.form.submit()" {{ 'checked' if m.feat_atk_planner_admin }}> 🎯 Planner Admin
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="toggle-group" style="opacity: 0.8;">
|
<div class="toggle-group" style="opacity: 0.8;">
|
||||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_farm else '#30363d' }}; color: {{ '#3fb950' if m.feat_farm else '#8b949e' }};">🌾 Farm</span>
|
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_farm else '#30363d' }}; color: {{ '#3fb950' if m.feat_farm else '#8b949e' }};">🌾 Farm</span>
|
||||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_admin else '#8b949e' }};">🏛 Admin</span>
|
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_admin else '#8b949e' }};">🏛 Admin</span>
|
||||||
|
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner else '#8b949e' }};">⚔️ Planner</span>
|
||||||
|
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_atk_planner_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_atk_planner_admin else '#8b949e' }};">🎯 Planner Admin</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td>
|
<td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
|
|||||||
Reference in New Issue
Block a user