agoara update
This commit is contained in:
@@ -816,6 +816,84 @@
|
||||
return { ok: true, msg: `Research ${research_id} queued` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Culture (Αγορά)
|
||||
// Fires a Γιορτή πόλης (party) or Παρέλαση θριάμβου (triumph)
|
||||
// celebration for a specific town via building_place/start_celebration.
|
||||
// ----------------------------------------------------------------
|
||||
async function executeCultureCommand(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { celebration_type } = payload || {};
|
||||
|
||||
if (!celebration_type || !town_id) {
|
||||
return { ok: false, msg: 'Invalid culture payload: missing celebration_type or town_id' };
|
||||
}
|
||||
if (!['party', 'triumph'].includes(celebration_type)) {
|
||||
return { ok: false, msg: `Unknown celebration_type: ${celebration_type}` };
|
||||
}
|
||||
|
||||
const label = celebration_type === 'party' ? 'Γιορτή πόλης' : 'Παρέλαση θριάμβου';
|
||||
log(`[αγορά] Εκτέλεση ${label} για πόλη ${town_id}`);
|
||||
|
||||
// Validate town exists in game memory
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) {
|
||||
return { ok: false, msg: `Η πόλη ${town_id} δεν βρέθηκε στη μνήμη του παιχνιδιού.` };
|
||||
}
|
||||
|
||||
// Double-check: no active celebration of this type running
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (String(a.town_id) === String(town_id)
|
||||
&& a.celebration_type === celebration_type
|
||||
&& (a.finished_at ?? 0) > nowTs) {
|
||||
return { ok: false, msg: `${label} ήδη ενεργή στην πόλη ${town_id}.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* model not loaded — proceed; server already validated */ }
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`[αγορά] Waiting ${reactionMs}ms (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
// Fire the Grepolis AJAX
|
||||
let ajaxSuccess = false;
|
||||
let ajaxError = null;
|
||||
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
uw.gpAjax.ajaxPost(
|
||||
'building_place',
|
||||
'start_celebration',
|
||||
{ town_id: parseInt(town_id, 10), celebration_type },
|
||||
false,
|
||||
() => { ajaxSuccess = true; resolve(); },
|
||||
(err) => { ajaxError = err ? JSON.stringify(err) : 'Άγνωστο σφάλμα AJAX'; resolve(); }
|
||||
);
|
||||
} catch (e) {
|
||||
ajaxError = String(e);
|
||||
resolve();
|
||||
}
|
||||
// 12-second safety timeout
|
||||
setTimeout(() => { if (!ajaxSuccess && !ajaxError) ajaxError = 'Timeout (12s)'; resolve(); }, 12000);
|
||||
});
|
||||
|
||||
if (ajaxSuccess) {
|
||||
log(`[αγορά] ✅ ${label} ξεκίνησε επιτυχώς (πόλη ${town_id})`);
|
||||
return { ok: true, msg: `${label} ξεκίνησε επιτυχώς.` };
|
||||
} else {
|
||||
log(`[αγορά] ❌ Αποτυχία: ${ajaxError}`);
|
||||
return { ok: false, msg: ajaxError || 'Αποτυχία εκτέλεσης εορτής.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Poll for and execute pending commands (build + recruit + market)
|
||||
// ----------------------------------------------------------------
|
||||
@@ -841,6 +919,7 @@
|
||||
const researchCmd = cmdData.research;
|
||||
const farmCmd = cmdData.farm;
|
||||
const farmUpgradeCmd = cmdData.farm_upgrade;
|
||||
const cultureCmd = cmdData.culture;
|
||||
|
||||
|
||||
|
||||
@@ -865,6 +944,7 @@
|
||||
else if (cmd.type === 'research') result = await executeResearch(cmd);
|
||||
else if (cmd.type === 'farm_loot') result = await executeFarmLoot(cmd);
|
||||
else if (cmd.type === 'farm_upgrade') result = await executeFarmUpgrade(cmd);
|
||||
else if (cmd.type === 'culture') result = await executeCultureCommand(cmd);
|
||||
else result = { ok: false, msg: `Unknown type: ${cmd.type}` };
|
||||
} catch (e) {
|
||||
result = { ok: false, msg: `Exception: ${e}` };
|
||||
@@ -889,6 +969,7 @@
|
||||
await execute(researchCmd);
|
||||
await execute(farmCmd);
|
||||
await execute(farmUpgradeCmd);
|
||||
await execute(cultureCmd);
|
||||
|
||||
// Auto-farm: if enabled, claim all ready farms (no explicit command needed)
|
||||
const farmSettings = cmdData.farm_settings || {};
|
||||
|
||||
@@ -247,11 +247,31 @@ function gatherState() {
|
||||
}
|
||||
} catch (e) { log(`unit speed gather failed: ${e}`); }
|
||||
|
||||
|
||||
// Active celebrations — tracks running parties/processions per town
|
||||
// finished_at is a unix timestamp (seconds). 0 means not running.
|
||||
const celebrations = [];
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (!a || !a.town_id) continue;
|
||||
celebrations.push({
|
||||
town_id: String(a.town_id),
|
||||
celebration_type: a.celebration_type, // 'party' | 'triumph'
|
||||
finished_at: a.finished_at ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { log(`celebrations gather failed: ${e}`); }
|
||||
|
||||
return { player, player_id, alliance_id, total_points, battle_points, world_id: world,
|
||||
world_speed, unit_speeds, towns: townList };
|
||||
world_speed, unit_speeds, towns: townList, celebrations };
|
||||
|
||||
}
|
||||
|
||||
|
||||
function pushState() {
|
||||
if (paused) return;
|
||||
try {
|
||||
|
||||
101
bot_modules/04d_execute_culture.js
Normal file
101
bot_modules/04d_execute_culture.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// ================================================================
|
||||
// 04d_execute_culture.js — Execute Αγορά celebration commands
|
||||
//
|
||||
// Handles commands of type 'culture' from the poll response.
|
||||
// Supported celebration_type values:
|
||||
// 'party' → Γιορτή πόλης (costs wood/stone/iron)
|
||||
// 'triumph' → Παρέλαση θριάμβου (costs battle points)
|
||||
//
|
||||
// On success/failure, reports back to /api/commands/<id>/result
|
||||
// so the culture_log row is updated from 'pending' to 'success'/'failed'.
|
||||
// ================================================================
|
||||
|
||||
async function executeCultureCommand(cmd) {
|
||||
if (!cmd || !cmd.id || !cmd.payload) return;
|
||||
|
||||
const { celebration_type, town_id } = cmd.payload;
|
||||
if (!celebration_type || !town_id) {
|
||||
reportResult(cmd.id, 'failed', 'Invalid culture payload: missing celebration_type or town_id');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['party', 'triumph'].includes(celebration_type)) {
|
||||
reportResult(cmd.id, 'failed', `Unknown celebration_type: ${celebration_type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[αγορά] Εκτέλεση ${celebration_type === 'party' ? 'Γιορτής πόλης' : 'Παρέλασης θριάμβου'} για πόλη ${town_id}`);
|
||||
|
||||
// Validate town still exists in game memory
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) {
|
||||
reportResult(cmd.id, 'failed', `Η πόλη ${town_id} δεν βρέθηκε στη μνήμη του παιχνιδιού.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-check: is there already a celebration of this type running?
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (String(a.town_id) === String(town_id)
|
||||
&& a.celebration_type === celebration_type
|
||||
&& (a.finished_at ?? 0) > nowTs) {
|
||||
reportResult(cmd.id, 'failed',
|
||||
`${celebration_type === 'party' ? 'Γιορτή' : 'Παρέλαση'} ήδη ενεργή στην πόλη ${town_id}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* model not loaded — proceed anyway, server will error if invalid */ }
|
||||
|
||||
// Fire the Grepolis AJAX
|
||||
let ajaxSuccess = false;
|
||||
let ajaxError = null;
|
||||
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
uw.gpAjax.ajaxPost(
|
||||
'building_place',
|
||||
'start_celebration',
|
||||
{
|
||||
town_id: parseInt(town_id, 10),
|
||||
celebration_type: celebration_type
|
||||
},
|
||||
false, // no cache-busting
|
||||
// success callback
|
||||
() => {
|
||||
ajaxSuccess = true;
|
||||
resolve();
|
||||
},
|
||||
// error callback
|
||||
(err) => {
|
||||
ajaxError = err ? JSON.stringify(err) : 'Άγνωστο σφάλμα AJAX';
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
ajaxError = String(e);
|
||||
resolve();
|
||||
}
|
||||
|
||||
// Safety timeout: if neither callback fires within 12 s, treat as failed
|
||||
setTimeout(() => {
|
||||
if (!ajaxSuccess && !ajaxError) {
|
||||
ajaxError = 'Timeout — δεν ελήφθη απάντηση από το παιχνίδι εντός 12δ.';
|
||||
}
|
||||
resolve();
|
||||
}, 12000);
|
||||
});
|
||||
|
||||
if (ajaxSuccess) {
|
||||
const label = celebration_type === 'party' ? 'Γιορτή πόλης' : 'Παρέλαση θριάμβου';
|
||||
log(`[αγορά] ✅ ${label} ξεκίνησε επιτυχώς (πόλη ${town_id})`);
|
||||
reportResult(cmd.id, 'done', `${label} ξεκίνησε επιτυχώς.`);
|
||||
} else {
|
||||
log(`[αγορά] ❌ Αποτυχία: ${ajaxError}`);
|
||||
reportResult(cmd.id, 'failed', ajaxError || 'Αποτυχία εκτέλεσης εορτής.');
|
||||
}
|
||||
}
|
||||
43
db.py
43
db.py
@@ -85,6 +85,7 @@ def init_db():
|
||||
CREATE TABLE IF NOT EXISTS bot_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
feature TEXT NOT NULL, -- 'bootcamp' | 'rural_trade'
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -92,6 +93,46 @@ def init_db():
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_bot_logs_player_feature ON bot_logs(player_id, feature)')
|
||||
|
||||
# Celebrations — active celebration state per town, pushed by the bot
|
||||
# One row per town_id + celebration_type. Upserted on every state push.
|
||||
# finished_at = 0 means no active celebration of that type.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS celebrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
town_name TEXT,
|
||||
celebration_type TEXT NOT NULL, -- 'party' | 'triumph'
|
||||
finished_at INTEGER NOT NULL DEFAULT 0, -- unix timestamp (0 = not running)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(player_id, world_id, town_id, celebration_type)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_celebrations_player_world ON celebrations(player_id, world_id)')
|
||||
|
||||
# Culture log — immutable audit log of every Αγορά command fired from the dashboard
|
||||
# Records what was fired, what it cost, and whether the bot confirmed success.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS culture_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
town_name TEXT,
|
||||
celebration_type TEXT NOT NULL, -- 'party' | 'triumph'
|
||||
cost_wood INTEGER NOT NULL DEFAULT 0,
|
||||
cost_stone INTEGER NOT NULL DEFAULT 0,
|
||||
cost_iron INTEGER NOT NULL DEFAULT 0,
|
||||
cost_battle_pts INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
|
||||
result_msg TEXT,
|
||||
fired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
confirmed_at TEXT
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_culture_log_player_world ON culture_log(player_id, world_id)')
|
||||
|
||||
# Blueprints - assigns a blueprint to a specific town
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS town_blueprints (
|
||||
@@ -184,6 +225,8 @@ def init_db():
|
||||
"ALTER TABLE clan_members ADD COLUMN features TEXT NOT NULL DEFAULT 'farm,admin'",
|
||||
'ALTER TABLE clan_members ADD COLUMN world_id TEXT',
|
||||
'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)',
|
||||
# bot_logs gained world_id for per-world isolation
|
||||
"ALTER TABLE bot_logs ADD COLUMN world_id TEXT NOT NULL DEFAULT ''",
|
||||
]:
|
||||
try:
|
||||
c.execute(_col)
|
||||
|
||||
@@ -139,3 +139,35 @@ Players manually open the World Wonder window and use UI helpers to calculate ho
|
||||
**Benefits:**
|
||||
- Maximizes alliance contributions to World Wonders around the clock.
|
||||
- Perfectly balances resources sent so the player doesn't accidentally empty their town of a critical resource.
|
||||
|
||||
## 6. On-Demand Market Capacity & Trade Order Monitor
|
||||
**Inspired by:** `3rdparty/ModernBot.user.js` (`getAllTrades`) and `3rdparty/DIO-TOOLS-David1327 stamasPacket.user.js` (transport capacity logic)
|
||||
|
||||
**Background & Why Previous Attempts Failed:**
|
||||
Earlier attempts to read market capacity via `town.getAvailableTradeCapacity()` and active orders via `uw.MM.getCollections().Trade` consistently returned empty/zero values. The root cause: both APIs depend on Grepolis having pre-loaded the town's trade models in memory, which only happens if the user has recently opened that specific town's market window. For towns the user hasn't visited this session, the models are simply absent.
|
||||
|
||||
**Proposed Implementation (On-Demand, Single AJAX Call):**
|
||||
- Add a **[Sync Market 🔄]** button in the dashboard town details panel.
|
||||
- On click, the dashboard sends a `POST /api/market-sync-request` to Flask.
|
||||
- The bot detects the pending request during its next poll cycle and executes a single AJAX call:
|
||||
```javascript
|
||||
uw.gpAjax.ajaxGet('town_overviews', 'trade_overview', {}, true, e => {
|
||||
// e.towns_merchants → available & total merchant capacity per town
|
||||
// e.movements → all active incoming & outgoing shipments
|
||||
});
|
||||
```
|
||||
- The `true` flag fetches data for **all towns simultaneously** — one call, full empire snapshot.
|
||||
- The bot pushes the result to Flask via `POST /api/market-state`.
|
||||
- The dashboard polls and renders:
|
||||
- **Capacity** inline in the town detail panel (e.g., `Merchants: 8 / 15`)
|
||||
- **Shipments** as a small list: `→ Αθήνα: 2,500 🪵 (12m 30s)` / `← Σπάρτη: 1,800 ⛏️ (3m)`
|
||||
|
||||
**UI Layout (TBD):**
|
||||
- Capacity shown inline with resources (compact).
|
||||
- Shipments shown in a collapsible section or modal — to be decided with user.
|
||||
|
||||
**Benefits:**
|
||||
- Zero automatic polling — completely stealthy, fires only when the user explicitly requests it.
|
||||
- Bypasses the model-dependency problem entirely by going straight to the Grepolis server.
|
||||
- A single AJAX call populates market data for all towns at once, making it highly efficient.
|
||||
- Follows the existing Live Sync button pattern — no new architectural concepts needed.
|
||||
|
||||
@@ -120,11 +120,38 @@ def receive_state():
|
||||
except Exception as e:
|
||||
print("Error evaluating blueprints:", e)
|
||||
|
||||
# Upsert active celebrations (party / triumph cooldowns per town)
|
||||
celebrations = data.get('celebrations', [])
|
||||
if celebrations and player_id and world_id:
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
for cel in celebrations:
|
||||
town_id_cel = str(cel.get('town_id', ''))
|
||||
cel_type = cel.get('celebration_type', '')
|
||||
finished_at = int(cel.get('finished_at', 0))
|
||||
if not town_id_cel or not cel_type:
|
||||
continue
|
||||
# Resolve town_name from what we just upserted
|
||||
t_name_row = c.execute(
|
||||
'SELECT town_name FROM town_state WHERE town_id = ?', (town_id_cel,)
|
||||
).fetchone()
|
||||
t_name = t_name_row['town_name'] if t_name_row else ''
|
||||
c.execute('''\
|
||||
INSERT INTO celebrations
|
||||
(player_id, world_id, town_id, town_name, celebration_type, finished_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id, world_id, town_id, celebration_type) DO UPDATE SET
|
||||
town_name = excluded.town_name,
|
||||
finished_at = excluded.finished_at,
|
||||
updated_at = excluded.updated_at
|
||||
''', (str(player_id), world_id, town_id_cel, t_name, cel_type, finished_at, now_iso))
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'towns_updated': len(towns)})
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/commands/pending
|
||||
# Tampermonkey polls this to get the next command to execute.
|
||||
@@ -266,11 +293,12 @@ def get_pending_command():
|
||||
''', (two_minutes_ago, player_id))
|
||||
|
||||
build_cmds = _fetch_pending_builds_all_towns(c, player_id, world_id) # one per town
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id, world_id)
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id, world_id)
|
||||
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id, world_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id, world_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id, world_id)
|
||||
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id, world_id)
|
||||
research_cmd = _fetch_pending_of_type(c, 'research', player_id, world_id)
|
||||
research_cmd = _fetch_pending_of_type(c, 'research', player_id, world_id)
|
||||
culture_cmd = _fetch_pending_of_type(c, 'culture', player_id, world_id)
|
||||
sync_req = _check_and_reset_sync(c, player_id)
|
||||
|
||||
# Determine player_key for world-specific settings if world_id is provided
|
||||
@@ -324,6 +352,7 @@ def get_pending_command():
|
||||
'research': research_cmd,
|
||||
'farm': farm_cmd,
|
||||
'farm_upgrade': farm_upgrade_cmd,
|
||||
'culture': culture_cmd,
|
||||
'farm_settings': farm_settings,
|
||||
'bot_settings': bot_settings,
|
||||
'enabled_features': enabled_features,
|
||||
@@ -393,11 +422,26 @@ def command_result(cmd_id):
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (lf_key, now, now))
|
||||
|
||||
# When a culture command finishes, update the matching culture_log row
|
||||
if cmd and cmd['type'] == 'culture' and cmd['player_id']:
|
||||
log_status = 'success' if status == 'done' else ('failed' if status == 'failed' else 'pending')
|
||||
conn.execute('''\
|
||||
UPDATE culture_log
|
||||
SET status = ?, result_msg = ?, confirmed_at = ?
|
||||
WHERE player_id = ? AND status = 'pending'
|
||||
AND id = (
|
||||
SELECT id FROM culture_log
|
||||
WHERE player_id = ? AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
''', (log_status, msg, now, str(cmd['player_id']), str(cmd['player_id'])))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/captcha/alert
|
||||
# Tampermonkey reports when #hcaptcha_window appears/disappears.
|
||||
|
||||
@@ -97,6 +97,11 @@ def player_farm(player_id, world_id):
|
||||
def player_tracker(player_id, world_id):
|
||||
return render_template('tracker.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/agora')
|
||||
@login_required
|
||||
def player_agora(player_id, world_id):
|
||||
return render_template('agora.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/farm-settings — returns current farm config
|
||||
# POST /dashboard/farm-settings — updates farm config
|
||||
@@ -464,8 +469,9 @@ def create_command():
|
||||
return jsonify({'error': f'missing field: {field}'}), 400
|
||||
|
||||
cmd_type = data['type']
|
||||
if cmd_type not in ('build', 'recruit', 'market_offer', 'farm_loot', 'farm_upgrade', 'research'):
|
||||
return jsonify({'error': 'type must be build, recruit, market_offer, farm_loot, farm_upgrade, or research'}), 400
|
||||
if cmd_type not in ('build', 'recruit', 'market_offer', 'farm_loot', 'farm_upgrade', 'research', 'culture'):
|
||||
return jsonify({'error': 'type must be build, recruit, market_offer, farm_loot, farm_upgrade, research, or culture'}), 400
|
||||
|
||||
|
||||
# Reject if the Tampermonkey client is offline (no state push in last 150 s)
|
||||
conn = get_db()
|
||||
@@ -682,3 +688,269 @@ def bootcamp_attack_now():
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/agora
|
||||
# Returns per-town celebration eligibility for the Αγορά tab.
|
||||
# For each town owned by player_id + world_id we calculate:
|
||||
# • party — has 15k wood / 18k stone / 15k iron? on cooldown?
|
||||
# • triumph — has 300+ battle points? on cooldown?
|
||||
# ------------------------------------------------------------------
|
||||
PARTY_COST = {'wood': 15000, 'stone': 18000, 'iron': 15000}
|
||||
TRIUMPH_COST = 300 # battle points
|
||||
|
||||
@dashboard.route('/dashboard/agora', methods=['GET'])
|
||||
@login_required
|
||||
def get_agora():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
now_ts = int(datetime.utcnow().timestamp())
|
||||
|
||||
# ── Town snapshots ───────────────────────────────────────────────
|
||||
town_rows = conn.execute('''
|
||||
SELECT town_id, town_name, data
|
||||
FROM town_state
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY town_name ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
|
||||
# ── Active celebrations (cooldowns) ──────────────────────────────
|
||||
cel_rows = conn.execute('''
|
||||
SELECT town_id, celebration_type, finished_at
|
||||
FROM celebrations
|
||||
WHERE player_id = ? AND world_id = ? AND finished_at > ?
|
||||
''', (player_id, world_id, now_ts)).fetchall()
|
||||
|
||||
# Build a lookup: (town_id, cel_type) → finished_at
|
||||
cooldowns = {}
|
||||
for r in cel_rows:
|
||||
cooldowns[(r['town_id'], r['celebration_type'])] = r['finished_at']
|
||||
|
||||
conn.close()
|
||||
|
||||
# ── Per-town eligibility ─────────────────────────────────────────
|
||||
towns_out = []
|
||||
for row in town_rows:
|
||||
d = json.loads(row['data'])
|
||||
wood = d.get('wood', 0)
|
||||
stone = d.get('stone', 0)
|
||||
iron = d.get('iron', 0)
|
||||
bp = d.get('battle_points', {}).get('available', 0)
|
||||
academy = d.get('buildings', {}).get('academy', 0)
|
||||
|
||||
tid = row['town_id']
|
||||
|
||||
# ── Party (Γιορτή πόλης) ─────────────────────────────────────
|
||||
party_cd = cooldowns.get((tid, 'party'), 0)
|
||||
if party_cd:
|
||||
party_status = 'cooldown'
|
||||
party_reason = f'Ενεργή — λήγει σε {_fmt_seconds(party_cd - now_ts)}'
|
||||
party_ok = False
|
||||
elif academy < 30:
|
||||
party_status = 'unavailable'
|
||||
party_reason = f'Ακαδημία {academy}/30'
|
||||
party_ok = False
|
||||
elif wood < PARTY_COST['wood'] or stone < PARTY_COST['stone'] or iron < PARTY_COST['iron']:
|
||||
missing = []
|
||||
if wood < PARTY_COST['wood']: missing.append(f'ξύλο {wood:,}/{PARTY_COST["wood"]:,}')
|
||||
if stone < PARTY_COST['stone']: missing.append(f'πέτρα {stone:,}/{PARTY_COST["stone"]:,}')
|
||||
if iron < PARTY_COST['iron']: missing.append(f'σίδερο {iron:,}/{PARTY_COST["iron"]:,}')
|
||||
party_status = 'unavailable'
|
||||
party_reason = 'Ανεπαρκείς πόροι: ' + ', '.join(missing)
|
||||
party_ok = False
|
||||
else:
|
||||
party_status = 'available'
|
||||
party_reason = ''
|
||||
party_ok = True
|
||||
|
||||
# ── Triumph (Παρέλαση θριάμβου) ──────────────────────────────
|
||||
triumph_cd = cooldowns.get((tid, 'triumph'), 0)
|
||||
if triumph_cd:
|
||||
triumph_status = 'cooldown'
|
||||
triumph_reason = f'Ενεργή — λήγει σε {_fmt_seconds(triumph_cd - now_ts)}'
|
||||
triumph_ok = False
|
||||
elif bp < TRIUMPH_COST:
|
||||
triumph_status = 'unavailable'
|
||||
triumph_reason = f'Ανεπαρκείς πόντοι μάχης: {bp}/{TRIUMPH_COST}'
|
||||
triumph_ok = False
|
||||
else:
|
||||
triumph_status = 'available'
|
||||
triumph_reason = ''
|
||||
triumph_ok = True
|
||||
|
||||
towns_out.append({
|
||||
'town_id': tid,
|
||||
'town_name': row['town_name'],
|
||||
'resources': {'wood': wood, 'stone': stone, 'iron': iron},
|
||||
'battle_points': bp,
|
||||
'academy': academy,
|
||||
'party': {
|
||||
'status': party_status,
|
||||
'reason': party_reason,
|
||||
'available': party_ok,
|
||||
'cooldown_until': party_cd or None,
|
||||
},
|
||||
'triumph': {
|
||||
'status': triumph_status,
|
||||
'reason': triumph_reason,
|
||||
'available': triumph_ok,
|
||||
'cooldown_until': triumph_cd or None,
|
||||
},
|
||||
})
|
||||
|
||||
return jsonify({'towns': towns_out, 'costs': {'party': PARTY_COST, 'triumph': TRIUMPH_COST}})
|
||||
|
||||
|
||||
def _fmt_seconds(secs):
|
||||
"""Format a duration in seconds to a human-readable Greek string."""
|
||||
secs = max(0, int(secs))
|
||||
h, rem = divmod(secs, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f'{h}ω {m}λ'
|
||||
if m:
|
||||
return f'{m}λ {s}δ'
|
||||
return f'{s}δ'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/culture-command
|
||||
# Dashboard fires when the user confirms a celebration.
|
||||
# Body: { player_id, world_id, town_id, town_name, celebration_type }
|
||||
# • Validates client is online.
|
||||
# • Validates eligibility (resources / battle_points / cooldown).
|
||||
# • Inserts a 'culture' command into the commands queue.
|
||||
# • Inserts a 'pending' row into culture_log.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/culture-command', methods=['POST'])
|
||||
@login_required
|
||||
def culture_command():
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = data.get('player_id')
|
||||
world_id = data.get('world_id', '')
|
||||
town_id = str(data.get('town_id', ''))
|
||||
town_name = data.get('town_name', '')
|
||||
cel_type = data.get('celebration_type', '') # 'party' | 'triumph'
|
||||
|
||||
if not all([player_id, town_id, cel_type]):
|
||||
return jsonify({'error': 'missing player_id, town_id or celebration_type'}), 400
|
||||
if cel_type not in ('party', 'triumph'):
|
||||
return jsonify({'error': 'celebration_type must be party or triumph'}), 400
|
||||
|
||||
conn = get_db()
|
||||
|
||||
# ── Client online check ──────────────────────────────────────────
|
||||
row = conn.execute(
|
||||
'SELECT MAX(updated_at) AS last_seen FROM town_state WHERE player_id = ?', (player_id,)
|
||||
).fetchone()
|
||||
last_seen = row['last_seen'] if row else None
|
||||
client_online = False
|
||||
if last_seen:
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_seen)
|
||||
client_online = (datetime.utcnow() - dt).total_seconds() <= 150
|
||||
except Exception:
|
||||
pass
|
||||
if not client_online:
|
||||
conn.close()
|
||||
return jsonify({'error': 'client_offline',
|
||||
'message': 'Το script είναι offline — δεν μπορείτε να στείλετε εντολές.'}), 503
|
||||
|
||||
# ── Re-validate eligibility server-side ─────────────────────────
|
||||
town_row = conn.execute(
|
||||
'SELECT data FROM town_state WHERE town_id = ? AND player_id = ?', (town_id, player_id)
|
||||
).fetchone()
|
||||
if not town_row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'town not found'}), 404
|
||||
|
||||
d = json.loads(town_row['data'])
|
||||
now_t = int(datetime.utcnow().timestamp())
|
||||
|
||||
# Cooldown check
|
||||
cel_row = conn.execute('''
|
||||
SELECT finished_at FROM celebrations
|
||||
WHERE player_id = ? AND world_id = ? AND town_id = ? AND celebration_type = ? AND finished_at > ?
|
||||
''', (player_id, world_id, town_id, cel_type, now_t)).fetchone()
|
||||
if cel_row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'on_cooldown',
|
||||
'message': f'Η εορτή τρέχει ήδη — λήγει σε {_fmt_seconds(cel_row["finished_at"] - now_t)}'}), 409
|
||||
|
||||
# Cost determination & resource check
|
||||
if cel_type == 'party':
|
||||
cost_wood = PARTY_COST['wood']
|
||||
cost_stone = PARTY_COST['stone']
|
||||
cost_iron = PARTY_COST['iron']
|
||||
cost_bp = 0
|
||||
if (d.get('wood', 0) < cost_wood or d.get('stone', 0) < cost_stone
|
||||
or d.get('iron', 0) < cost_iron):
|
||||
conn.close()
|
||||
return jsonify({'error': 'insufficient_resources',
|
||||
'message': 'Ανεπαρκείς πόροι για τη Γιορτή πόλης.'}), 409
|
||||
else: # triumph
|
||||
cost_wood = cost_stone = cost_iron = 0
|
||||
cost_bp = TRIUMPH_COST
|
||||
available_bp = d.get('battle_points', {}).get('available', 0)
|
||||
if available_bp < TRIUMPH_COST:
|
||||
conn.close()
|
||||
return jsonify({'error': 'insufficient_battle_points',
|
||||
'message': f'Ανεπαρκείς πόντοι μάχης ({available_bp}/{TRIUMPH_COST}).'}), 409
|
||||
|
||||
# ── Insert command into bot queue ────────────────────────────────
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, 'culture', ?, 'pending', ?, ?, ?)
|
||||
''', (
|
||||
town_id, town_name,
|
||||
json.dumps({'celebration_type': cel_type, 'town_id': int(town_id)}),
|
||||
now_iso, now_iso, str(player_id)
|
||||
))
|
||||
cmd_id = c.lastrowid
|
||||
|
||||
# ── Append culture_log row (status=pending until bot confirms) ───
|
||||
c.execute('''
|
||||
INSERT INTO culture_log
|
||||
(player_id, world_id, town_id, town_name, celebration_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_battle_pts, status, fired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||
''', (str(player_id), world_id, town_id, town_name, cel_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_bp, now_iso))
|
||||
log_id = c.lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'cmd_id': cmd_id, 'log_id': log_id})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/culture-log
|
||||
# Returns the last 50 Αγορά log entries for a player + world.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/culture-log', methods=['GET'])
|
||||
@login_required
|
||||
def get_culture_log():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT id, town_id, town_name, celebration_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_battle_pts,
|
||||
status, result_msg, fired_at, confirmed_at
|
||||
FROM culture_log
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
672
templates/agora.html
Normal file
672
templates/agora.html
Normal file
@@ -0,0 +1,672 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Αγορά — Grepolis Remote</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--panel: #181824;
|
||||
--border: #2a2a3e;
|
||||
--purple: #b482dc;
|
||||
--purple-dim: rgba(180,130,220,0.12);
|
||||
--gold: #c8a44a;
|
||||
--green: #4acc64;
|
||||
--red: #e05555;
|
||||
--yellow: #e0b847;
|
||||
--text: #e0e0e0;
|
||||
--muted: #666;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
background: #13131f;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--purple);
|
||||
flex: 1;
|
||||
}
|
||||
.topbar a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
transition: color .2s;
|
||||
}
|
||||
.topbar a:hover { color: var(--text); }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
|
||||
/* ── Main layout ── */
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
/* ── Left panel ── */
|
||||
.left-panel {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 1rem 1.2rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-box {
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--purple); }
|
||||
|
||||
.town-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.town-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-bottom: 1px solid #1e1e2e;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
position: relative;
|
||||
}
|
||||
.town-row:hover { background: #1e1e30; }
|
||||
.town-row.active { background: var(--purple-dim); border-left: 3px solid var(--purple); }
|
||||
.town-row .t-name { font-size: 0.84rem; font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.town-row .t-dots { display: flex; gap: 4px; }
|
||||
.t-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.t-dot.available { background: var(--green); }
|
||||
.t-dot.cooldown { background: var(--yellow); }
|
||||
.t-dot.unavailable { background: #333; }
|
||||
|
||||
/* ── Right panel ── */
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cards-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.2rem;
|
||||
align-content: start;
|
||||
}
|
||||
.no-selection {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
/* ── Celebration cards ── */
|
||||
.cel-card {
|
||||
background: #1e1e30;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.cel-card.available { border-color: rgba(180,130,220,0.35); }
|
||||
.cel-card.cooldown { border-color: rgba(224,184,71,0.35); }
|
||||
.cel-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--purple);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cel-card.cooldown .cel-card-title { color: var(--yellow); }
|
||||
|
||||
.cost-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cost-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cost-item.ok { color: var(--green); }
|
||||
.cost-item.bad { color: var(--red); }
|
||||
.cost-item.neutral { color: var(--text); }
|
||||
|
||||
.reason-box {
|
||||
font-size: 0.78rem;
|
||||
color: var(--yellow);
|
||||
padding: 6px 10px;
|
||||
background: rgba(224,184,71,0.08);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(224,184,71,0.2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.reason-box.error {
|
||||
color: var(--red);
|
||||
background: rgba(224,85,85,0.08);
|
||||
border-color: rgba(224,85,85,0.2);
|
||||
}
|
||||
|
||||
.cel-btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
transition: all .2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.cel-btn.primary {
|
||||
background: var(--purple);
|
||||
color: #fff;
|
||||
}
|
||||
.cel-btn.primary:hover { background: #c99ef0; transform: translateY(-1px); }
|
||||
.cel-btn:disabled {
|
||||
background: #2a2a3e;
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Log panel ── */
|
||||
.log-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
background: #13131f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.log-header span { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.log-body {
|
||||
overflow-y: auto;
|
||||
max-height: 170px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.77rem;
|
||||
display: none;
|
||||
}
|
||||
.log-body.open { display: block; }
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a28;
|
||||
align-items: center;
|
||||
}
|
||||
.log-time { color: var(--muted); min-width: 55px; }
|
||||
.log-town { color: var(--purple); min-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.log-type { color: var(--text); min-width: 150px; }
|
||||
.log-status.success { color: var(--green); }
|
||||
.log-status.failed { color: var(--red); }
|
||||
.log-status.pending { color: var(--yellow); }
|
||||
.log-msg { color: var(--muted); font-size: 0.72rem; flex: 1; }
|
||||
.log-empty { color: var(--muted); padding: 0.6rem 0; font-size: 0.8rem; }
|
||||
|
||||
/* ── Confirm Modal ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal-box {
|
||||
background: #1e1e30;
|
||||
border: 1px solid var(--purple);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
min-width: 340px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(180,130,220,0.2);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: var(--purple);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.modal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-row span:first-child { color: var(--muted); }
|
||||
.modal-row span:last-child { font-weight: 600; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-cancel {
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: #2a2a3e;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-cancel:hover { border-color: var(--purple); }
|
||||
.btn-confirm {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--purple);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn-confirm:hover { background: #c99ef0; }
|
||||
.btn-confirm:disabled { background: #444; color: var(--muted); cursor: not-allowed; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.ok { background: rgba(74,204,100,0.15); border: 1px solid var(--green); color: var(--green); }
|
||||
.toast.err { background: rgba(224,85,85,0.15); border: 1px solid var(--red); color: var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">🎭 Αγορά</div>
|
||||
<span id="online-indicator"><span class="status-dot" id="status-dot"></span><span id="status-text">Φόρτωση...</span></span>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}">← Hub</a>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Left: Town List -->
|
||||
<div class="left-panel">
|
||||
<div class="panel-header">Πόλεις (<span id="town-count">0</span>)</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="search" placeholder="Αναζήτηση πόλης...">
|
||||
</div>
|
||||
<div class="town-list" id="town-list">
|
||||
<div style="padding:1.5rem;color:#555;font-size:0.82rem">Φόρτωση...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cards + Log -->
|
||||
<div class="right-panel">
|
||||
|
||||
<div class="cards-area" id="cards-area">
|
||||
<div class="no-selection">⬅ Επέλεξε πόλη για να δεις τις διαθέσιμες εορτές</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Panel -->
|
||||
<div class="log-panel">
|
||||
<div class="log-header" onclick="toggleLog()">
|
||||
<span>📜 Αρχείο Αγοράς</span>
|
||||
<span id="log-toggle-icon">▲</span>
|
||||
</div>
|
||||
<div class="log-body open" id="log-body">
|
||||
<div class="log-empty" id="log-empty">Δεν υπάρχουν εγγραφές ακόμα.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div class="modal-overlay" id="confirm-modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title" id="modal-title">Επιβεβαίωση Εορτής</div>
|
||||
<div id="modal-rows"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeModal()">Ακύρωση</button>
|
||||
<button class="btn-confirm" id="btn-confirm" onclick="fireCommand()">⚡ Εκτέλεση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
|
||||
const TYPE_LABELS = { party: 'Γιορτή πόλης 🎉', triumph: 'Παρέλαση θριάμβου ⚔️' };
|
||||
const PARTY_COSTS = { wood: 15000, stone: 18000, iron: 15000 };
|
||||
|
||||
let agoraData = [];
|
||||
let selectedId = null;
|
||||
let pendingCmd = null; // { town, cel_type }
|
||||
let logOpen = true;
|
||||
|
||||
// ── Fetch agora data ─────────────────────────────────────────────
|
||||
async function fetchAgora() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/agora?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const d = await r.json();
|
||||
agoraData = d.towns || [];
|
||||
document.getElementById('town-count').textContent = agoraData.length;
|
||||
renderTownList();
|
||||
if (selectedId) renderCards(agoraData.find(t => t.town_id === selectedId));
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Render town list ─────────────────────────────────────────────
|
||||
function renderTownList() {
|
||||
const q = document.getElementById('search').value.toLowerCase();
|
||||
const list = document.getElementById('town-list');
|
||||
const filtered = agoraData.filter(t => t.town_name.toLowerCase().includes(q));
|
||||
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = '<div style="padding:1rem;color:#555;font-size:0.82rem">Δεν βρέθηκαν πόλεις.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = filtered.map(t => `
|
||||
<div class="town-row ${t.town_id === selectedId ? 'active' : ''}"
|
||||
onclick="selectTown('${t.town_id}')">
|
||||
<span class="t-name">${t.town_name}</span>
|
||||
<span class="t-dots">
|
||||
<span class="t-dot ${t.party.status}" title="Γιορτή πόλης: ${t.party.status}"></span>
|
||||
<span class="t-dot ${t.triumph.status}" title="Παρέλαση: ${t.triumph.status}"></span>
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectTown(townId) {
|
||||
selectedId = townId;
|
||||
renderTownList();
|
||||
renderCards(agoraData.find(t => t.town_id === townId));
|
||||
}
|
||||
|
||||
// ── Render celebration cards ─────────────────────────────────────
|
||||
function renderCards(town) {
|
||||
const area = document.getElementById('cards-area');
|
||||
if (!town) {
|
||||
area.innerHTML = '<div class="no-selection">⬅ Επέλεξε πόλη για να δεις τις διαθέσιμες εορτές</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = `
|
||||
${celebCard(town, 'party')}
|
||||
${celebCard(town, 'triumph')}
|
||||
`;
|
||||
}
|
||||
|
||||
function celebCard(town, type) {
|
||||
const cel = town[type];
|
||||
const label = TYPE_LABELS[type];
|
||||
|
||||
let costsHtml = '';
|
||||
if (type === 'party') {
|
||||
const r = town.resources;
|
||||
costsHtml = `
|
||||
<div class="cost-row">
|
||||
<span class="cost-item ${r.wood >= PARTY_COSTS.wood ? 'ok' : 'bad'}">🪵 ${fmt(r.wood)} / ${fmt(PARTY_COSTS.wood)}</span>
|
||||
<span class="cost-item ${r.stone >= PARTY_COSTS.stone ? 'ok' : 'bad'}">🪨 ${fmt(r.stone)} / ${fmt(PARTY_COSTS.stone)}</span>
|
||||
<span class="cost-item ${r.iron >= PARTY_COSTS.iron ? 'ok' : 'bad'}">⚙️ ${fmt(r.iron)} / ${fmt(PARTY_COSTS.iron)}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
const needed = 300;
|
||||
costsHtml = `
|
||||
<div class="cost-row">
|
||||
<span class="cost-item ${town.battle_points >= needed ? 'ok' : 'bad'}">⚔️ ${fmt(town.battle_points)} / ${needed} πόντοι</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const reasonHtml = cel.reason
|
||||
? `<div class="reason-box ${cel.status === 'cooldown' ? '' : 'error'}">${cel.reason}</div>`
|
||||
: '';
|
||||
|
||||
const btnLabel = cel.status === 'cooldown' ? `⏰ ${cel.reason}`
|
||||
: cel.status === 'unavailable' ? '✖ Μη διαθέσιμο'
|
||||
: `▶ Εκκίνηση ${label}`;
|
||||
|
||||
return `
|
||||
<div class="cel-card ${cel.status}">
|
||||
<div class="cel-card-title">${label}</div>
|
||||
${costsHtml}
|
||||
${reasonHtml}
|
||||
<button class="cel-btn primary"
|
||||
${cel.available ? '' : 'disabled'}
|
||||
onclick="openModal('${town.town_id}','${town.town_name}','${type}')">
|
||||
${cel.available ? `▶ Εκκίνηση` : (cel.status === 'cooldown' ? `⏰ Σε αναμονή` : `✖ Μη διαθέσιμο`)}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
function openModal(townId, townName, celType) {
|
||||
pendingCmd = { townId, townName, celType };
|
||||
document.getElementById('modal-title').textContent = `Επιβεβαίωση — ${TYPE_LABELS[celType]}`;
|
||||
|
||||
let rows = `
|
||||
<div class="modal-row"><span>Πόλη</span><span>${townName}</span></div>`;
|
||||
|
||||
if (celType === 'party') {
|
||||
rows += `
|
||||
<div class="modal-row"><span>Ξύλο</span><span>-15.000</span></div>
|
||||
<div class="modal-row"><span>Πέτρα</span><span>-18.000</span></div>
|
||||
<div class="modal-row"><span>Σίδερο</span><span>-15.000</span></div>`;
|
||||
} else {
|
||||
rows += `<div class="modal-row"><span>Πόντοι Μάχης</span><span>-300</span></div>`;
|
||||
}
|
||||
|
||||
document.getElementById('modal-rows').innerHTML = rows;
|
||||
document.getElementById('btn-confirm').disabled = false;
|
||||
document.getElementById('confirm-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('confirm-modal').classList.remove('open');
|
||||
pendingCmd = null;
|
||||
}
|
||||
|
||||
async function fireCommand() {
|
||||
if (!pendingCmd) return;
|
||||
document.getElementById('btn-confirm').disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch('/dashboard/culture-command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
world_id: WORLD_ID,
|
||||
town_id: pendingCmd.townId,
|
||||
town_name: pendingCmd.townName,
|
||||
celebration_type: pendingCmd.celType
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
closeModal();
|
||||
if (r.ok && d.ok) {
|
||||
showToast(`✅ Εντολή στάλθηκε — ${TYPE_LABELS[pendingCmd.celType]}`, 'ok');
|
||||
fetchLog();
|
||||
setTimeout(fetchAgora, 3000);
|
||||
} else {
|
||||
showToast(`❌ ${d.message || d.error || 'Αποτυχία'}`, 'err');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`❌ Σφάλμα: ${e}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Log ──────────────────────────────────────────────────────────
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
document.getElementById('log-body').classList.toggle('open', logOpen);
|
||||
document.getElementById('log-toggle-icon').textContent = logOpen ? '▲' : '▼';
|
||||
}
|
||||
|
||||
async function fetchLog() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/culture-log?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const rows = await r.json();
|
||||
const body = document.getElementById('log-body');
|
||||
const empty = document.getElementById('log-empty');
|
||||
|
||||
if (!rows.length) {
|
||||
if (empty) empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const existing = new Set([...body.querySelectorAll('.log-entry')].map(el => el.dataset.id));
|
||||
|
||||
rows.forEach(row => {
|
||||
if (existing.has(String(row.id))) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'log-entry';
|
||||
el.dataset.id = row.id;
|
||||
|
||||
const t = new Date(row.fired_at + 'Z');
|
||||
const time = t.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
const costStr = row.cost_battle_pts
|
||||
? `-${row.cost_battle_pts} πόντοι`
|
||||
: `-${fmt(row.cost_wood)} ξύλο / -${fmt(row.cost_stone)} πέτρα / -${fmt(row.cost_iron)} σίδερο`;
|
||||
|
||||
el.innerHTML = `
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-town">${row.town_name}</span>
|
||||
<span class="log-type">${TYPE_LABELS[row.celebration_type] || row.celebration_type}</span>
|
||||
<span class="log-status ${row.status}">${row.status === 'success' ? '✅' : row.status === 'failed' ? '❌' : '⏳'}</span>
|
||||
<span class="log-msg">${costStr}${row.result_msg ? ' — ' + row.result_msg : ''}</span>`;
|
||||
body.insertBefore(el, body.firstChild);
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Online status ────────────────────────────────────────────────
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/client-status?player_id=${PLAYER_ID}`);
|
||||
const d = await r.json();
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
dot.className = 'status-dot' + (d.online ? ' online' : '');
|
||||
text.textContent = d.online ? 'Online' : 'Offline';
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────
|
||||
function showToast(msg, type) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type} show`;
|
||||
setTimeout(() => { t.className = 'toast'; }, 3500);
|
||||
}
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────
|
||||
document.getElementById('search').addEventListener('input', renderTownList);
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
fetchAgora();
|
||||
fetchLog();
|
||||
checkStatus();
|
||||
setInterval(fetchAgora, 30000);
|
||||
setInterval(fetchLog, 15000);
|
||||
setInterval(checkStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -100,6 +100,11 @@
|
||||
.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); }
|
||||
|
||||
/* Αγορά — purple/gold */
|
||||
.hub-card.agora { border-color: #2d1f3f; }
|
||||
.hub-card.agora::before { background: radial-gradient(circle at top left, rgba(180,130,220,0.08), transparent 70%); }
|
||||
.hub-card.agora:hover { border-color: #b482dc; box-shadow: 0 12px 40px rgba(180,130,220,0.18); }
|
||||
|
||||
.card-icon {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -113,6 +118,7 @@
|
||||
.hub-card.admin .card-title { color: #c8a44a; }
|
||||
.hub-card.farm .card-title { color: #4acc64; }
|
||||
.hub-card.tracker .card-title { color: #6fcfcf; }
|
||||
.hub-card.agora .card-title { color: #b482dc; }
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.875rem;
|
||||
@@ -173,6 +179,12 @@
|
||||
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
|
||||
</a>
|
||||
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/agora" class="hub-card agora">
|
||||
<span class="card-icon">🎭</span>
|
||||
<div class="card-title">Αγορά</div>
|
||||
<div class="card-desc">Εκκίνηση Γιορτής πόλης και Παρέλασης θριάμβου ανά πόλη. Έλεγχος πόρων, cooldown και ιστορικό εκτελέσεων.</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<a href="/" class="back-link">← Επιστροφή στην επιλογή παίκτη</a>
|
||||
|
||||
Reference in New Issue
Block a user