agoara update

This commit is contained in:
2026-05-06 23:41:52 +03:00
parent 7e98f1292e
commit b824144a6a
9 changed files with 1283 additions and 6 deletions

View File

@@ -816,6 +816,84 @@
return { ok: true, msg: `Research ${research_id} queued` }; 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) // Poll for and execute pending commands (build + recruit + market)
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -841,6 +919,7 @@
const researchCmd = cmdData.research; const researchCmd = cmdData.research;
const farmCmd = cmdData.farm; const farmCmd = cmdData.farm;
const farmUpgradeCmd = cmdData.farm_upgrade; 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 === 'research') result = await executeResearch(cmd);
else if (cmd.type === 'farm_loot') result = await executeFarmLoot(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 === 'farm_upgrade') result = await executeFarmUpgrade(cmd);
else if (cmd.type === 'culture') result = await executeCultureCommand(cmd);
else result = { ok: false, msg: `Unknown type: ${cmd.type}` }; else result = { ok: false, msg: `Unknown type: ${cmd.type}` };
} catch (e) { } catch (e) {
result = { ok: false, msg: `Exception: ${e}` }; result = { ok: false, msg: `Exception: ${e}` };
@@ -889,6 +969,7 @@
await execute(researchCmd); await execute(researchCmd);
await execute(farmCmd); await execute(farmCmd);
await execute(farmUpgradeCmd); await execute(farmUpgradeCmd);
await execute(cultureCmd);
// Auto-farm: if enabled, claim all ready farms (no explicit command needed) // Auto-farm: if enabled, claim all ready farms (no explicit command needed)
const farmSettings = cmdData.farm_settings || {}; const farmSettings = cmdData.farm_settings || {};

View File

@@ -247,11 +247,31 @@ function gatherState() {
} }
} catch (e) { log(`unit speed gather failed: ${e}`); } } 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, 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() { function pushState() {
if (paused) return; if (paused) return;
try { try {

View 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
View File

@@ -85,6 +85,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS bot_logs ( CREATE TABLE IF NOT EXISTS bot_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id TEXT NOT NULL, player_id TEXT NOT NULL,
world_id TEXT NOT NULL DEFAULT '',
feature TEXT NOT NULL, -- 'bootcamp' | 'rural_trade' feature TEXT NOT NULL, -- 'bootcamp' | 'rural_trade'
message TEXT NOT NULL, message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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)') 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 # Blueprints - assigns a blueprint to a specific town
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS town_blueprints ( 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 features TEXT NOT NULL DEFAULT 'farm,admin'",
'ALTER TABLE clan_members ADD COLUMN world_id TEXT', 'ALTER TABLE clan_members ADD COLUMN world_id TEXT',
'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)', 'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)',
# bot_logs gained world_id for per-world isolation
"ALTER TABLE bot_logs ADD COLUMN world_id TEXT NOT NULL DEFAULT ''",
]: ]:
try: try:
c.execute(_col) c.execute(_col)

View File

@@ -139,3 +139,35 @@ Players manually open the World Wonder window and use UI helpers to calculate ho
**Benefits:** **Benefits:**
- Maximizes alliance contributions to World Wonders around the clock. - 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. - 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.

View File

@@ -120,11 +120,38 @@ def receive_state():
except Exception as e: except Exception as e:
print("Error evaluating blueprints:", 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() conn.close()
return jsonify({'ok': True, 'towns_updated': len(towns)}) return jsonify({'ok': True, 'towns_updated': len(towns)})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/commands/pending # GET /api/commands/pending
# Tampermonkey polls this to get the next command to execute. # Tampermonkey polls this to get the next command to execute.
@@ -266,11 +293,12 @@ def get_pending_command():
''', (two_minutes_ago, player_id)) ''', (two_minutes_ago, player_id))
build_cmds = _fetch_pending_builds_all_towns(c, player_id, world_id) # one per town 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) 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) 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) sync_req = _check_and_reset_sync(c, player_id)
# Determine player_key for world-specific settings if world_id is provided # Determine player_key for world-specific settings if world_id is provided
@@ -324,6 +352,7 @@ def get_pending_command():
'research': research_cmd, 'research': research_cmd,
'farm': farm_cmd, 'farm': farm_cmd,
'farm_upgrade': farm_upgrade_cmd, 'farm_upgrade': farm_upgrade_cmd,
'culture': culture_cmd,
'farm_settings': farm_settings, 'farm_settings': farm_settings,
'bot_settings': bot_settings, 'bot_settings': bot_settings,
'enabled_features': enabled_features, '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 ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
''', (lf_key, now, now)) ''', (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.commit()
conn.close() conn.close()
return jsonify({'ok': True}) return jsonify({'ok': True})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /api/captcha/alert # POST /api/captcha/alert
# Tampermonkey reports when #hcaptcha_window appears/disappears. # Tampermonkey reports when #hcaptcha_window appears/disappears.

View File

@@ -97,6 +97,11 @@ def player_farm(player_id, world_id):
def player_tracker(player_id, world_id): def player_tracker(player_id, world_id):
return render_template('tracker.html', player_id=player_id, world_id=world_id) return render_template('tracker.html', player_id=player_id, world_id=world_id)
@dashboard.route('/player/<player_id>/<world_id>/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 # GET /dashboard/farm-settings — returns current farm config
# POST /dashboard/farm-settings — updates farm config # POST /dashboard/farm-settings — updates farm config
@@ -464,8 +469,9 @@ def create_command():
return jsonify({'error': f'missing field: {field}'}), 400 return jsonify({'error': f'missing field: {field}'}), 400
cmd_type = data['type'] cmd_type = data['type']
if cmd_type not in ('build', 'recruit', 'market_offer', 'farm_loot', 'farm_upgrade', 'research'): 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, or research'}), 400 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) # Reject if the Tampermonkey client is offline (no state push in last 150 s)
conn = get_db() conn = get_db()
@@ -682,3 +688,269 @@ def bootcamp_attack_now():
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({'ok': True}) 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
View 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>

View File

@@ -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::before { background: radial-gradient(circle at top left, rgba(111,207,207,0.08), transparent 70%); }
.hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); } .hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); }
/* Αγορά — 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 { .card-icon {
font-size: 2.8rem; font-size: 2.8rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -113,6 +118,7 @@
.hub-card.admin .card-title { color: #c8a44a; } .hub-card.admin .card-title { color: #c8a44a; }
.hub-card.farm .card-title { color: #4acc64; } .hub-card.farm .card-title { color: #4acc64; }
.hub-card.tracker .card-title { color: #6fcfcf; } .hub-card.tracker .card-title { color: #6fcfcf; }
.hub-card.agora .card-title { color: #b482dc; }
.card-desc { .card-desc {
font-size: 0.875rem; font-size: 0.875rem;
@@ -173,6 +179,12 @@
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div> <div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
</a> </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> </div>
<a href="/" class="back-link">← Επιστροφή στην επιλογή παίκτη</a> <a href="/" class="back-link">← Επιστροφή στην επιλογή παίκτη</a>