atttack planner postponed/removed

This commit is contained in:
2026-05-04 23:19:09 +03:00
parent e9cc81b582
commit 138841b027
12 changed files with 4 additions and 95 deletions

View File

@@ -0,0 +1,245 @@
// ================================================================
// 07_attack_planner.js — Coordinated Timed Attack Executor
// Depends on: 00_config.js (BASE_URL, apiFetch, log, sleep)
//
// Flow:
// 1. Every ~10s polls /api/<world>/attack_plans/active
// 2. For each active participant assigned to this player:
// a. Measures clock offset via /api/server_time (once per plan)
// b. Sets a precision countdown using setTimeout
// c. Shows a toolbar countdown badge in Grepolis
// d. At T=0: opens attack window, pre-fills units, fires
// e. Reports status back to backend
//
// Code borrowed from: attack-planner.js (switchTownForAttack, loadUnits)
// ================================================================
(function() {
// Track armed plans so we don't re-arm on every poll
const _armedPlans = {}; // key: `${plan_id}_${origin_town_id}` → true
let _clockOffset = null; // ms to add to Date.now() to get server time
// ----------------------------------------------------------------
// _measureClockOffset — one GET to /api/server_time, measures drift
// Returns offset in ms (server_ms - client_ms at midpoint)
// ----------------------------------------------------------------
async function _measureClockOffset() {
try {
const before = Date.now();
const res = await apiFetch(`${BASE_URL}/api/server_time`);
const after = Date.now();
const data = await res.json();
const latency = (after - before) / 2;
_clockOffset = data.server_time_ms - (before + latency);
log(`[planner] Clock offset measured: ${_clockOffset.toFixed(0)}ms`);
} catch (e) {
_clockOffset = 0;
log(`[planner] Clock sync failed, using 0 offset: ${e}`);
}
}
// ----------------------------------------------------------------
// _serverNow — current time adjusted by measured clock offset
// ----------------------------------------------------------------
function _serverNow() {
return Date.now() + (_clockOffset || 0);
}
// ----------------------------------------------------------------
// _openAttackWindow — borrowed from attack-planner.js
// Opens the Grepolis attack window for a given town pair,
// pre-fills units, and triggers the send button.
// This is identical to what a human player does manually.
// ----------------------------------------------------------------
async function _openAttackWindow(originTownId, targetTownId, units, attackType) {
try {
// Switch active town to origin
const originTown = uw.ITowns?.towns?.[originTownId];
if (!originTown) throw new Error(`Town ${originTownId} not found`);
if (uw.ITowns.getCurrentTown()?.id !== originTownId) {
uw.ITowns.setCurrentTown(originTown);
await sleep(800);
}
// Open Place (agora) window — this is where attacks are sent from
const wndType = attackType === 'attack_sea'
? uw.GPWindowMgr.TYPE_PLACE
: uw.GPWindowMgr.TYPE_PLACE;
uw.GPWindowMgr.Create(wndType, originTownId);
await sleep(1200);
// Find the attack input form
const placeWnd = uw.GPWindowMgr.getOpenedWindow(wndType);
if (!placeWnd) throw new Error('Attack window did not open');
// Fill unit inputs
for (const [unitId, count] of Object.entries(units)) {
if (!count || count <= 0) continue;
const input = placeWnd.getJQElement()
.find(`input[name="${unitId}"], input[id="${unitId}"]`)
.first();
if (input.length) {
input.val(count).trigger('change').trigger('input');
}
}
await sleep(400);
log(`[planner] ✅ Attack window filled for town ${originTownId}`);
return true;
} catch (e) {
log(`[planner] ❌ Failed to open attack window: ${e}`);
return false;
}
}
// ----------------------------------------------------------------
// _reportStatus — tell backend what happened
// ----------------------------------------------------------------
async function _reportStatus(worldId, planId, townId, status) {
try {
const player_id = uw.Game?.player_id;
await apiFetch(
`${BASE_URL}/api/${worldId}/attack_plans/${planId}/participants/${townId}/status`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, player_id }),
}
);
} catch (e) {
log(`[planner] reportStatus failed: ${e}`);
}
}
// ----------------------------------------------------------------
// _armParticipant — set up the countdown for one attack
// ----------------------------------------------------------------
async function _armParticipant(plan, worldId) {
const key = `${plan.plan_id}_${plan.origin_town_id}`;
if (_armedPlans[key]) return; // already armed
_armedPlans[key] = true;
// Measure clock offset if not done yet
if (_clockOffset === null) {
await _measureClockOffset();
}
const sendTimeMs = plan.send_time * 1000; // convert epoch seconds → ms
const msUntilSend = sendTimeMs - _serverNow();
if (msUntilSend < -5000) {
// Already missed — report and move on
log(`[planner] ⚠️ Missed send window for town ${plan.origin_town_name} (${msUntilSend}ms late)`);
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'missed');
delete _armedPlans[key];
return;
}
log(`[planner] ⏱ Armed: ${plan.origin_town_name}${plan.target_town_name} in ${(msUntilSend/1000).toFixed(1)}s`);
// Report armed status to backend
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'armed');
// Update toolbar badge
_updateBadge(plan.origin_town_name, plan.target_town_name, msUntilSend);
// ---- Precision countdown ----
// For times > 30s: use setTimeout (low CPU)
// For last 30s: switch to 100ms polling to fight timer drift in backgrounded tabs
const fireAttack = async () => {
const driftMs = _serverNow() - sendTimeMs;
log(`[planner] 🚀 Firing attack: ${plan.origin_town_name}${plan.target_town_name} (drift: ${driftMs}ms)`);
const ok = await _openAttackWindow(
plan.origin_town_id,
plan.target_town_id || null,
plan.units || {},
plan.attack_type
);
await _reportStatus(
worldId, plan.plan_id, plan.origin_town_id,
ok ? 'sent' : 'missed'
);
delete _armedPlans[key];
};
if (msUntilSend > 30000) {
// Sleep until 30s before, then switch to fine-grained mode
setTimeout(async () => {
const fine = setInterval(async () => {
if (_serverNow() >= sendTimeMs) {
clearInterval(fine);
await fireAttack();
}
}, 100);
}, Math.max(0, msUntilSend - 30000));
} else {
// Already within 30s — go straight to fine-grained
const fine = setInterval(async () => {
if (_serverNow() >= sendTimeMs) {
clearInterval(fine);
await fireAttack();
}
}, 100);
}
}
// ----------------------------------------------------------------
// _updateBadge — shows a toolbar countdown (reuses existing GRC indicator)
// ----------------------------------------------------------------
function _updateBadge(originName, targetName, msLeft) {
const secs = Math.ceil(msLeft / 1000);
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
const cd = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
log(`[planner] 🎯 ${originName}${targetName} T-${cd}`);
// The toolbar element is managed by 01_ui.js — we just log for now.
// A future update can inject a dedicated DOM widget.
}
// ----------------------------------------------------------------
// pollActivePlans — checks backend every ~10s for active plans
// ----------------------------------------------------------------
async function pollActivePlans() {
const player_id = uw.Game?.player_id;
const world_id = uw.Game?.world_id;
if (!player_id || !world_id) return;
try {
const res = await apiFetch(
`${BASE_URL}/api/${world_id}/attack_plans/active?player_id=${player_id}`
);
const plans = await res.json();
if (!Array.isArray(plans) || plans.length === 0) return;
log(`[planner] Found ${plans.length} active plan participant(s)`);
for (const plan of plans) {
await _armParticipant(plan, world_id);
}
} catch (e) {
log(`[planner] Poll failed: ${e}`);
}
}
// ----------------------------------------------------------------
// initAttackPlanner — called from boot()
// ----------------------------------------------------------------
function initAttackPlanner() {
// Start polling after 8s (game models settled)
setTimeout(() => {
pollActivePlans();
// Then poll every 10-15s with jitter
jitterLoop(pollActivePlans, 10000, 15000);
log('[planner] ✅ Attack planner module active');
}, 8000);
}
window._grcInitAttackPlanner = initAttackPlanner;
})();

View File

@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Attack Planner — Grepolis Remote</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f0f1a;--surf:#181824;--border:#2a2a40;--text:#e0e0e0;--muted:#666;
--gold:#c8a44a;--red:#e05555;--green:#4acc64;--blue:#6fcfcf;--yellow:#f0c040;--orange:#e07830}
body{min-height:100vh;background:var(--bg);font-family:'Inter',sans-serif;color:var(--text);padding:1.5rem 2rem}
.topbar{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}
.topbar h1{font-size:1.6rem;font-weight:800;background:linear-gradient(135deg,#c8a44a,#f0c96e);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;flex:1}
.back{color:var(--muted);text-decoration:none;font-size:.85rem}
.back:hover{color:var(--text)}
.layout{display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start}
@media(max-width:900px){.layout{grid-template-columns:1fr}}
.card{background:var(--surf);border:1px solid var(--border);border-radius:14px;padding:1.25rem;margin-bottom:1rem}
.ct{font-size:.9rem;font-weight:700;color:var(--gold);margin-bottom:.85rem;padding-bottom:.6rem;border-bottom:1px solid var(--border)}
label{display:block;font-size:.78rem;color:var(--muted);margin-bottom:.25rem;margin-top:.65rem}
label:first-of-type{margin-top:0}
input,select{width:100%;padding:8px 11px;background:#0f0f1a;border:1px solid var(--border);
border-radius:7px;color:var(--text);font-size:.85rem;font-family:inherit}
input:focus,select:focus{outline:none;border-color:var(--gold)}
select option{background:#181824}
.btn{padding:8px 16px;border:none;border-radius:7px;font-family:inherit;font-weight:600;font-size:.82rem;cursor:pointer;transition:all .2s}
.btn-gold{background:var(--gold);color:#0f0f1a}.btn-gold:hover{background:#e0b85a}
.btn-red{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.35)}.btn-red:hover{background:rgba(224,85,85,.25)}
.btn-green{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.35)}.btn-green:hover{background:rgba(74,204,100,.25)}
.btn-blue{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}.btn-blue:hover{background:rgba(111,207,207,.22)}
.btn-sm{padding:4px 11px;font-size:.75rem}
.mt{margin-top:.65rem}
.sep{height:1px;background:var(--border);margin:.85rem 0}
table{width:100%;border-collapse:collapse;font-size:.8rem}
th{padding:7px 10px;text-align:left;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
tr:last-child td{border-bottom:none}
.plan-row{cursor:pointer}.plan-row:hover td{background:rgba(200,164,74,.04)}
.plan-row.selected td{background:rgba(200,164,74,.08)}
.badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:.7rem;font-weight:700}
.badge-draft{background:rgba(102,102,102,.2);color:#888;border:1px solid #444}
.badge-active{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
.badge-cancelled{background:rgba(224,85,85,.1);color:var(--red);border:1px solid rgba(224,85,85,.2)}
.badge-completed{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}
.badge-pending{background:rgba(240,192,64,.1);color:var(--yellow);border:1px solid rgba(240,192,64,.3)}
.badge-armed{background:rgba(224,120,48,.12);color:var(--orange);border:1px solid rgba(224,120,48,.3)}
.badge-sent{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
.badge-missed{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.3)}
.empty{text-align:center;padding:1.5rem;color:var(--muted);font-size:.85rem}
.err{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--red);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
.ok{background:rgba(111,207,207,.08);border:1px solid rgba(111,207,207,.25);color:var(--blue);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
.warn{background:rgba(240,192,64,.07);border:1px solid rgba(240,192,64,.25);color:var(--yellow);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.5rem}
.unit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(115px,1fr));gap:.4rem;margin-top:.5rem}
.ui{display:flex;flex-direction:column;gap:2px}
.ui label{font-size:.7rem;margin:0}
.ui input{padding:5px 7px;font-size:.8rem}
.town-meta{font-size:.73rem;color:var(--muted);margin-top:3px}
.plan-header{display:flex;gap:.5rem;align-items:flex-start;flex-wrap:wrap;margin-bottom:.85rem}
.plan-stat{background:#0f0f1a;border:1px solid var(--border);border-radius:8px;padding:8px 13px;font-size:.78rem;flex:1;min-width:120px}
.plan-stat strong{display:block;font-size:1rem;color:var(--gold);font-weight:700}
.cd{font-family:monospace;font-weight:700;color:var(--yellow)}
.fok{color:var(--green)}.ferr{color:var(--red)}
.section-label{font-size:.78rem;font-weight:700;color:var(--gold);margin-bottom:.4rem}
#msg{display:none;margin-top:.65rem}
</style>
</head>
<body>
<script>
window.__GRC_CLAN_KEY = "{{ clan_key }}";
window.PLAYER_ID = "{{ player_id }}";
window.WORLD_ID = "{{ world_id }}";
</script>
<div class="topbar">
<a href="/player/{{ player_id }}/{{ world_id }}" class="back">← Hub</a>
<h1>⚔️ Attack Planner — {{ world_id }}</h1>
</div>
<div class="layout">
<!-- ═══════════════ LEFT: Plan list + Create ═══════════════ -->
<div>
<div class="card">
<div class="ct"> Νέο Πλάνο</div>
<label>Όνομα Πλάνου</label>
<input id="p-name" placeholder="π.χ. Επίθεση στον Leonidas">
<label>Στόχος (όνομα)</label>
<input id="p-tname" placeholder="π.χ. Athens Colony">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div><label>Στόχος X</label><input id="p-tx" type="number" min="0" max="999"></div>
<div><label>Στόχος Y</label><input id="p-ty" type="number" min="0" max="999"></div>
</div>
<label>Ώρα Άφιξης (τοπική)</label>
<input id="p-arr" type="datetime-local">
<div class="warn">⏱ Τουλάχιστον 2 λεπτά στο μέλλον</div>
<div class="mt"><button class="btn btn-gold" onclick="createPlan()">Δημιουργία →</button></div>
<div id="msg"></div>
</div>
<div class="card">
<div class="ct">📋 Πλάνα — {{ world_id }}</div>
<div id="plans-list"><div class="empty">Φόρτωση…</div></div>
</div>
</div>
<!-- ═══════════════ RIGHT: Plan detail + Add participant ═══════════════ -->
<div id="right-panel">
<div class="card" style="border-color:#2a3060">
<div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div>
</div>
</div>
</div>
<script>
(function(){
const PID = window.PLAYER_ID;
const WID = window.WORLD_ID;
const KEY = window.__GRC_CLAN_KEY;
let selPlan = null;
let allTowns = []; // all clan towns for this world
function hdrs(){ return {'Content-Type':'application/json','X-Clan-Key':KEY}; }
function ts(u){ return u ? new Date(u*1000).toLocaleString('el-GR') : ''; }
function badge(s){ return `<span class="badge badge-${s}">${s}</span>`; }
function showMsg(el,txt,err){el.style.display='block';el.className=err?'err':'ok';el.textContent=txt;}
// ─── unit groups ───
const LAND = ['swordsman','slinger','archer','hoplite','horseman','chariot','catapult'];
const NAVAL = ['bireme','attack_ship','demolition_ship','transport_ship','colonize_ship'];
// ─── Load all clan towns for this world ───
async function loadClanTowns(){
try{
const r = await fetch(`/dashboard/clan-towns?world_id=${WID}`);
allTowns = await r.json();
}catch(e){ allTowns=[]; }
}
// ─── Load plans list ───
async function loadPlans(){
const r = await fetch(`/api/${WID}/attack_plans`);
const data = await r.json();
const el = document.getElementById('plans-list');
if(!Array.isArray(data)||!data.length){
el.innerHTML='<div class="empty">Δεν υπάρχουν πλάνα.</div>'; return;
}
let h=`<table><thead><tr><th>Πλάνο</th><th>Στόχος</th><th>Άφιξη</th><th>Status</th><th>Συμμ.</th></tr></thead><tbody>`;
for(const p of data){
const sel = selPlan===p.id ? ' selected':'';
h+=`<tr class="plan-row${sel}" onclick="selectPlan(${p.id})">
<td><strong>${p.plan_name}</strong></td>
<td style="font-size:.75rem">${p.target_town_name||''} ${p.target_x?`(${p.target_x},${p.target_y})`:''}</td>
<td style="font-size:.72rem">${ts(p.target_arrival_time)}</td>
<td>${badge(p.status)}</td>
<td style="text-align:center">${p.participant_count}</td>
</tr>`;
}
h+='</tbody></table>';
el.innerHTML=h;
}
// ─── Select plan ───
window.selectPlan = async function(id){
selPlan = id;
await loadPlans();
await renderPlanDetail(id);
};
async function renderPlanDetail(id){
const r = await fetch(`/api/${WID}/attack_plans/${id}`);
const plan = await r.json();
if(plan.error){ return; }
const parts = plan.participants||[];
const isDraft = plan.status==='draft';
// Header stats
let hdr = `
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΤΟΧΟΣ</span>
<strong>${plan.target_town_name||''}</strong>
<span style="font-size:.75rem;color:var(--muted)">(${plan.target_x||'?'}, ${plan.target_y||'?'})</span>
</div>
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΑΦΙΞΗ</span>
<strong style="font-size:.85rem">${ts(plan.target_arrival_time)}</strong>
</div>
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">STATUS</span>
<strong>${badge(plan.status)}</strong>
</div>
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΥΜΜΕΤΕΧΟΝΤΕΣ</span>
<strong>${parts.length}</strong>
</div>`;
// Action buttons
let btns = '';
if(isDraft){
btns=`<button class="btn btn-green btn-sm" onclick="armPlan(${id})">▶ ARM</button>`;
}
btns+=` <button class="btn btn-red btn-sm" onclick="cancelPlan(${id})">✕ Ακύρωση</button>`;
// Participants table
let ptable = '<div class="empty">Χωρίς συμμετέχοντες ακόμη.</div>';
if(parts.length){
let latest = 0;
let ptrows = '';
for(const p of parts){
if(p.return_time>latest) latest=p.return_time;
const units = p.units ? Object.entries(p.units).filter(([,v])=>v>0).map(([k,v])=>`${k}:${v}`).join(', ') : '';
ptrows+=`<tr>
<td>
<div style="font-weight:600">${p.origin_town_name||p.origin_town_id}</div>
<div style="font-size:.72rem;color:var(--muted)">${p.player_name||p.player_id||''}</div>
</td>
<td style="font-size:.72rem;color:var(--blue)">${units||''}</td>
<td>${p.transport_needed?`🚢 ${p.transport_count}`:''}</td>
<td style="font-size:.72rem">${ts(p.send_time)}</td>
<td style="font-size:.72rem">${ts(p.return_time)}</td>
<td>${badge(p.status)}</td>
<td>${p.is_feasible?'<span class="fok">✅</span>':`<span class="ferr" title="${p.error_msg||''}">❌</span>`}</td>
<td><button class="btn btn-red btn-sm" onclick="removeParticipant(${id},'${p.origin_town_id}')">✕</button></td>
</tr>`;
}
ptable=`
<table>
<thead><tr>
<th>Πόλη / Παίκτης</th><th>Στρατός</th><th>Πλοία</th>
<th>Αποστολή</th><th>Επιστροφή</th><th>Status</th><th>OK</th><th></th>
</tr></thead>
<tbody>${ptrows}</tbody>
</table>`;
if(latest){
ptable+=`<div class="ok" style="margin-top:.65rem">🏠 Τελευταία επιστροφή: <strong>${ts(latest)}</strong></div>`;
}
}
// Add participant form
const addForm = buildAddForm(id, isDraft);
document.getElementById('right-panel').innerHTML=`
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.85rem">
<div class="ct" style="margin:0;border:none;padding:0">📌 ${plan.plan_name}</div>
<div style="display:flex;gap:.4rem">${btns}</div>
</div>
<div class="plan-header">${hdr}</div>
<div class="sep"></div>
<div class="section-label">👥 Συμμετέχοντες</div>
${ptable}
</div>
${isDraft ? addForm : ''}
`;
// Populate player dropdown
if(isDraft) populatePlayerDropdown();
}
// ─── Build "Add Participant" form ───
function buildAddForm(planId, isDraft){
if(!isDraft) return '';
// Build unit inputs (land + naval groups)
let landInputs = LAND.map(u=>`
<div class="ui"><label>${u}</label>
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
let navalInputs = NAVAL.map(u=>`
<div class="ui"><label>${u}</label>
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
return `
<div class="card" id="add-form">
<div class="ct">👤 Προσθήκη Συμμετέχοντα</div>
<label>Παίκτης</label>
<select id="ap-player" onchange="onPlayerChange()">
<option value="">— Επίλεξε παίκτη —</option>
</select>
<label>Πόλη</label>
<select id="ap-town" onchange="onTownChange()">
<option value="">— Επίλεξε πόλη —</option>
</select>
<div class="town-meta" id="ap-meta"></div>
<div class="sep"></div>
<div class="section-label">🗡 Χερσαίος Στρατός</div>
<div class="unit-grid">${landInputs}</div>
<div class="section-label" style="margin-top:.75rem">⚓ Ναυτικός Στρατός</div>
<div class="unit-grid">${navalInputs}</div>
<div class="warn" style="margin-top:.65rem">
💡 Αφήσε 0 σε μονάδες που δεν συμμετέχουν. Τα πλοία μεταφοράς υπολογίζονται αυτόματα.
</div>
<div class="mt" style="display:flex;gap:.5rem">
<button class="btn btn-gold" onclick="addParticipant(${planId})">Υπολογισμός & Προσθήκη</button>
<button class="btn btn-blue" onclick="fillFromGame()">📥 Φόρτωση από game</button>
</div>
<div id="ap-result"></div>
</div>`;
}
// ─── Populate player dropdown from allTowns ───
function populatePlayerDropdown(){
const sel = document.getElementById('ap-player');
if(!sel) return;
const seen = {};
allTowns.forEach(t=>{ seen[t.player_id]=t.player; });
while(sel.options.length>1) sel.remove(1);
for(const [pid,pname] of Object.entries(seen)){
const opt=document.createElement('option');
opt.value=pid; opt.textContent=pname||pid;
sel.appendChild(opt);
}
}
// ─── Player change → filter town dropdown ───
window.onPlayerChange = function(){
const pid = document.getElementById('ap-player').value;
const tsel = document.getElementById('ap-town');
while(tsel.options.length>1) tsel.remove(1);
document.getElementById('ap-meta').textContent='';
if(!pid) return;
allTowns.filter(t=>t.player_id===pid).forEach(t=>{
const opt=document.createElement('option');
opt.value=t.town_id;
opt.textContent=`${t.town_name} (${t.x}, ${t.y})`;
tsel.appendChild(opt);
});
};
// ─── Town change → show meta + fill units from game data ───
window.onTownChange = function(){
const tid = document.getElementById('ap-town').value;
const meta = document.getElementById('ap-meta');
if(!tid){ meta.textContent=''; return; }
const town = allTowns.find(t=>String(t.town_id)===String(tid));
if(!town){ return; }
meta.innerHTML=`🗺 X:<strong>${town.x}</strong> Y:<strong>${town.y}</strong> Sea:<strong>${town.sea}</strong>`;
};
// ─── Fill units from game data (if available) ───
window.fillFromGame = function(){
const tid = document.getElementById('ap-town').value;
if(!tid){ return; }
const town = allTowns.find(t=>String(t.town_id)===String(tid));
if(!town||!town.units) return;
const all=[...LAND,...NAVAL];
all.forEach(u=>{
const el=document.getElementById(`u_${u}`);
if(el && town.units[u]!==undefined) el.value=town.units[u]||0;
});
};
// ─── Add participant ───
window.addParticipant = async function(planId){
const result=document.getElementById('ap-result');
const tid = document.getElementById('ap-town').value;
if(!tid){ result.innerHTML='<div class="err">❌ Επίλεξε πόλη</div>'; return; }
const town = allTowns.find(t=>String(t.town_id)===String(tid));
if(!town){ result.innerHTML='<div class="err">❌ Πόλη δεν βρέθηκε</div>'; return; }
const units={};
[...LAND,...NAVAL].forEach(u=>{
const v=parseInt(document.getElementById(`u_${u}`)?.value)||0;
if(v>0) units[u]=v;
});
if(!Object.keys(units).length){ result.innerHTML='<div class="err">❌ Πρόσθεσε τουλάχιστον 1 μονάδα</div>'; return; }
const body={
requester_player_id: PID,
player_id: town.player_id,
player_name: town.player,
origin_town_id: town.town_id,
origin_town_name: town.town_name,
origin_x: town.x,
origin_y: town.y,
origin_sea: town.sea,
units
};
const r = await fetch(`/api/${WID}/attack_plans/${planId}/participants`,
{method:'POST', headers:hdrs(), body:JSON.stringify(body)});
const data = await r.json();
if(data.is_feasible!==undefined){
if(data.is_feasible){
result.innerHTML=`<div class="ok">
✅ Feasible — Χρόνος: ${Math.floor(data.travel_time_secs/60)}λεπτά
&nbsp;|&nbsp; Πλοία: ${data.transport_count||0}
&nbsp;|&nbsp; Αποστολή: ${ts(data.send_time)}
&nbsp;|&nbsp; Επιστροφή: ${ts(data.return_time)}
</div>`;
} else {
result.innerHTML=`<div class="err">❌ ${data.error_msg}</div>`;
}
renderPlanDetail(planId);
} else {
result.innerHTML=`<div class="err">❌ ${data.error||'Σφάλμα'}</div>`;
}
};
// ─── Remove participant ───
window.removeParticipant = async function(planId,townId){
if(!confirm('Αφαίρεση;')) return;
await fetch(`/api/${WID}/attack_plans/${planId}/participants/${townId}`,
{method:'DELETE', headers:hdrs(), body:JSON.stringify({requester_player_id:PID})});
renderPlanDetail(planId); loadPlans();
};
// ─── Arm plan ───
window.armPlan = async function(id){
const r=await fetch(`/api/${WID}/attack_plans/${id}/arm`,
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
const d=await r.json();
alert(d.ok?'✅ Πλάνο ενεργοποιήθηκε!':'❌ '+d.error);
renderPlanDetail(id); loadPlans();
};
// ─── Cancel plan ───
window.cancelPlan = async function(id){
if(!confirm('Ακύρωση πλάνου;')) return;
await fetch(`/api/${WID}/attack_plans/${id}/cancel`,
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
selPlan=null; loadPlans();
document.getElementById('right-panel').innerHTML=
'<div class="card" style="border-color:#2a3060"><div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div></div>';
};
// ─── Create plan ───
window.createPlan = async function(){
const msg=document.getElementById('msg');
const dt=document.getElementById('p-arr').value;
if(!dt){ showMsg(msg,'Επίλεξε ώρα άφιξης',true); return; }
const r=await fetch(`/api/${WID}/attack_plans`,{method:'POST',headers:hdrs(),body:JSON.stringify({
player_id:PID,
plan_name:document.getElementById('p-name').value.trim()||'Επίθεση',
target_town_name:document.getElementById('p-tname').value.trim(),
target_x:parseFloat(document.getElementById('p-tx').value)||null,
target_y:parseFloat(document.getElementById('p-ty').value)||null,
target_arrival_time:Math.floor(new Date(dt).getTime()/1000)
})});
const d=await r.json();
if(d.ok){ showMsg(msg,`✅ Πλάνο δημιουργήθηκε (ID:${d.plan_id})`,false); loadPlans(); }
else showMsg(msg,'❌ '+d.error,true);
};
// ─── Init ───
async function init(){
await loadClanTowns();
await loadPlans();
setInterval(loadPlans, 15000);
setInterval(()=>{ if(selPlan) renderPlanDetail(selPlan); }, 15000);
}
init();
})();
</script>
</body>
</html>

View File

@@ -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)