Files
grepo-remote/routes/api.py
2026-05-07 20:22:28 +03:00

801 lines
32 KiB
Python

from flask import Blueprint, request, jsonify
from db import get_db
import json
from datetime import datetime, timedelta
import os
from flask import make_response
from blueprint_engine import evaluate_blueprints
import logging
_log = logging.getLogger(__name__)
api = Blueprint('api', __name__)
# ------------------------------------------------------------------
# Helper — look up clan by the X-Clan-Key header.
# Returns the clan row dict, or None if key is missing / invalid.
# ------------------------------------------------------------------
def _get_clan_from_request():
key = request.headers.get('X-Clan-Key', '').strip()
if not key:
return None
conn = get_db()
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
conn.close()
return clan
# ------------------------------------------------------------------
# Helper — auto-register a player_id under a clan on first push.
# ------------------------------------------------------------------
def _auto_register_member(clan_id, player_id, player_name, world_id=''):
world_id = world_id or ''
conn = get_db()
conn.execute('''
INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name, world_id)
VALUES (?, ?, ?, ?)
''', (clan_id, str(player_id), player_name or '', world_id))
# Update name on every push (it can change); world_id is part of the key so no overwrite risk
conn.execute('''
UPDATE clan_members SET player_name = ?
WHERE clan_id = ? AND player_id = ? AND world_id = ?
''', (player_name or '', clan_id, str(player_id), world_id))
conn.commit()
conn.close()
# ------------------------------------------------------------------
# POST /api/state
# Tampermonkey pushes a full town snapshot every poll cycle.
# ------------------------------------------------------------------
@api.route('/api/state', methods=['POST'])
def receive_state():
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'no data'}), 400
towns = data.get('towns', [])
player = data.get('player', '')
player_id = data.get('player_id', '')
alliance_id = str(data.get('alliance_id', '') or '')
world_id = data.get('world_id', '')
battle_points = data.get('battle_points', {})
# Auto-register this player to the clan that matches the key (if any)
clan = _get_clan_from_request()
if clan:
_auto_register_member(clan['id'], player_id, player, world_id)
conn = get_db()
c = conn.cursor()
for town in towns:
x = town.get('x')
y = town.get('y')
sea = town.get('sea')
town['battle_points'] = battle_points
c.execute('''
INSERT INTO town_state
(town_id, town_name, player, player_id, alliance_id, world_id, x, y, sea, data, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(town_id) DO UPDATE SET
town_name = excluded.town_name,
player = excluded.player,
player_id = excluded.player_id,
alliance_id = excluded.alliance_id,
world_id = excluded.world_id,
x = excluded.x,
y = excluded.y,
sea = excluded.sea,
data = excluded.data,
updated_at = excluded.updated_at
''', (
str(town['town_id']),
town.get('town_name', ''),
player,
player_id,
alliance_id,
world_id,
x, y, sea,
json.dumps(town),
datetime.utcnow().isoformat()
))
conn.commit()
# Store world speed + unit data
world_speed = data.get('world_speed')
unit_speeds = data.get('unit_speeds')
if world_id and world_speed is not None and unit_speeds:
world_data = json.dumps({'world_speed': world_speed, 'unit_speeds': unit_speeds})
c.execute('''
INSERT INTO kv_store (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
''', (f'world_data_{world_id}', world_data, datetime.utcnow().isoformat()))
conn.commit()
try:
evaluate_blueprints(conn)
conn.commit()
except Exception as e:
print("Error evaluating blueprints:", e)
# 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()
# ── Auto-culture: check per-town settings and queue if eligible ──
# Runs on every state push — no extra game polling needed.
_auto_culture_check(c, str(player_id), world_id, towns, battle_points)
conn.commit()
conn.close()
return jsonify({'ok': True, 'towns_updated': len(towns)})
# Cost constants (must match dashboard.py PARTY_COST / TRIUMPH_COST)
_PARTY_COST = {'wood': 15000, 'stone': 18000, 'iron': 15000}
_TRIUMPH_BP = 300
def _auto_culture_check(c, player_id, world_id, towns, battle_points):
"""Called after every state push.
For each town that has auto_party or auto_triumph enabled:
- skip if a pending/executing entry already exists in culture_queue
- skip if celebration cooldown hasn't expired yet
- skip if resources/battle-points are insufficient
- otherwise insert a new 'auto' entry into culture_queue
"""
if not player_id or not world_id:
return
now_ts = int(datetime.utcnow().timestamp())
now_iso = datetime.utcnow().isoformat()
bp_available = battle_points.get('available', 0) if isinstance(battle_points, dict) else 0
# Load auto settings for this player/world
auto_rows = c.execute(
'SELECT town_id, auto_party, auto_triumph FROM culture_settings WHERE player_id = ? AND world_id = ?',
(player_id, world_id)
).fetchall()
if not auto_rows:
return
# Build a quick lookup of town data from the state payload
town_map = {str(t.get('town_id', '')): t for t in towns}
for auto_row in auto_rows:
tid = str(auto_row['town_id'])
td = town_map.get(tid)
if not td:
continue # town not in this state push (shouldn't happen but guard anyway)
town_name = td.get('town_name', '')
for cel_type, enabled in [('party', auto_row['auto_party']), ('triumph', auto_row['auto_triumph'])]:
if not enabled:
continue
# 1. Already queued?
existing = c.execute(
"SELECT id FROM culture_queue WHERE player_id=? AND world_id=? AND celebration_type=? AND status IN ('pending','executing')",
(player_id, world_id, cel_type)
).fetchone()
if existing:
continue
# 2. Cooldown still active?
cel_cd = c.execute(
'SELECT finished_at FROM celebrations WHERE player_id=? AND world_id=? AND town_id=? AND celebration_type=?',
(player_id, world_id, tid, cel_type)
).fetchone()
if cel_cd and int(cel_cd['finished_at'] or 0) > now_ts:
continue # still cooling down
# 3. Resources check
if cel_type == 'party':
if (td.get('wood', 0) < _PARTY_COST['wood'] or
td.get('stone', 0) < _PARTY_COST['stone'] or
td.get('iron', 0) < _PARTY_COST['iron']):
continue # not enough yet — will retry on next state push
else: # triumph
if bp_available < _TRIUMPH_BP:
continue
# All clear — queue it
c.execute('''
INSERT INTO culture_queue
(player_id, world_id, town_id, town_name, celebration_type, status, source, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 'auto', ?)
''', (player_id, world_id, tid, town_name, cel_type, now_iso))
# Also log it
cost_w = _PARTY_COST['wood'] if cel_type == 'party' else 0
cost_s = _PARTY_COST['stone'] if cel_type == 'party' else 0
cost_i = _PARTY_COST['iron'] if cel_type == 'party' else 0
cost_b = _TRIUMPH_BP if cel_type == 'triumph' else 0
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, source, fired_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'auto', ?)
''', (player_id, world_id, tid, town_name, cel_type,
cost_w, cost_s, cost_i, cost_b, now_iso))
# ------------------------------------------------------------------
# GET /api/commands/pending
# Tampermonkey polls this to get the next command to execute.
# Returns one 'build' AND one 'recruit' command independently,
# so both queues are served in parallel without blocking each other.
# ------------------------------------------------------------------
def _fetch_pending_of_type(c, cmd_type, player_id, world_id):
"""Fetch a single oldest pending command of a given type (recruit, market, etc.)."""
# We use LEFT JOIN because global commands (like farm_loot) use a pseudo town_id like "0_gr121"
# which does not exist in town_state.
global_town_id = f"0_{world_id}" if world_id else "0"
if world_id:
row = c.execute('''
SELECT c.* FROM commands c
LEFT JOIN town_state ts ON c.town_id = ts.town_id
WHERE c.status = 'pending' AND c.type = ? AND c.player_id = ?
AND (ts.world_id = ? OR c.town_id = ?)
ORDER BY c.updated_at ASC, c.id ASC
LIMIT 1
''', (cmd_type, player_id, world_id, global_town_id)).fetchone()
else:
row = c.execute('''
SELECT * FROM commands
WHERE status = 'pending' AND type = ? AND player_id = ?
ORDER BY updated_at ASC, id ASC
LIMIT 1
''', (cmd_type, player_id)).fetchone()
if not row:
return None
c.execute('''\
UPDATE commands
SET status = 'executing', updated_at = ?
WHERE id = ?
''', (datetime.utcnow().isoformat(), row['id']))
return {
'id': row['id'],
'town_id': row['town_id'],
'type': row['type'],
'payload': json.loads(row['payload'])
}
def _fetch_pending_culture(c, player_id, world_id):
"""Fetch one pending culture command from the dedicated culture_queue table.
Completely separate from the commands table — no interference with builds/recruits.
Also times out stuck 'executing' rows to 'failed' after 5 minutes.
"""
now = datetime.utcnow().isoformat()
five_min_ago = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
# Expire stuck executing entries (fail, don't requeue — auto will re-fire on next state push)
c.execute('''
UPDATE culture_queue
SET status = 'failed', result_msg = 'Timeout (5 min)'
WHERE status = 'executing' AND executed_at < ? AND player_id = ?
''', (five_min_ago, player_id))
# Fetch one pending row
if world_id:
row = c.execute('''
SELECT * FROM culture_queue
WHERE status = 'pending' AND player_id = ? AND world_id = ?
ORDER BY created_at ASC
LIMIT 1
''', (player_id, world_id)).fetchone()
else:
row = c.execute('''
SELECT * FROM culture_queue
WHERE status = 'pending' AND player_id = ?
ORDER BY created_at ASC
LIMIT 1
''', (player_id,)).fetchone()
if not row:
return None
c.execute('''
UPDATE culture_queue SET status = 'executing', executed_at = ? WHERE id = ?
''', (now, row['id']))
return {
'id': row['id'],
'town_id': row['town_id'],
'type': 'culture',
'payload': {
'town_id': row['town_id'],
'celebration_type': row['celebration_type'],
'source': row['source']
}
}
def _fetch_pending_builds_all_towns(c, player_id, world_id):
"""
Fetch ONE pending 'build' command per distinct town_id.
This allows all towns to build in parallel within a single poll cycle.
Within each town the oldest-updated command is picked first, so requeued
commands (updated_at = now) naturally sort behind fresh ones.
Towns that already have a command in 'executing' state are skipped —
this prevents a second build from being dispatched before the first one
has reported its result (which was causing commands to pile up in EXECUTING).
"""
# Towns that currently have a build already in-flight — don't touch those.
if world_id:
executing_rows = c.execute('''
SELECT DISTINCT c.town_id FROM commands c
JOIN town_state ts ON c.town_id = ts.town_id
WHERE c.status = 'executing' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
''', (player_id, world_id)).fetchall()
else:
executing_rows = c.execute('''
SELECT DISTINCT town_id FROM commands
WHERE status = 'executing' AND type = 'build' AND player_id = ?
''', (player_id,)).fetchall()
busy_towns = {r['town_id'] for r in executing_rows}
# Get every town that has at least one pending build, ordered by
# which town has been waiting longest (MIN updated_at across its commands).
if world_id:
town_rows = c.execute('''
SELECT c.town_id
FROM commands c
JOIN town_state ts ON c.town_id = ts.town_id
WHERE c.status = 'pending' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
GROUP BY c.town_id
ORDER BY MIN(c.updated_at) ASC
''', (player_id, world_id)).fetchall()
else:
town_rows = c.execute('''
SELECT town_id
FROM commands
WHERE status = 'pending' AND type = 'build' AND player_id = ?
GROUP BY town_id
ORDER BY MIN(updated_at) ASC
''', (player_id,)).fetchall()
_log.warning(f"[poll] build towns found: {[r['town_id'] for r in town_rows]}, busy: {busy_towns}")
results = []
now = datetime.utcnow().isoformat()
for town_row in town_rows:
town_id = town_row['town_id']
# Skip this town if a build is already executing for it
if town_id in busy_towns:
continue
row = c.execute('''
SELECT * FROM commands
WHERE status = 'pending' AND type = 'build'
AND player_id = ? AND town_id = ?
ORDER BY position ASC, id ASC
LIMIT 1
''', (player_id, town_id)).fetchone()
if not row:
continue
c.execute('''
UPDATE commands SET status = 'executing', updated_at = ?
WHERE id = ?
''', (now, row['id']))
results.append({
'id': row['id'],
'town_id': row['town_id'],
'type': row['type'],
'payload': json.loads(row['payload'])
})
return results
@api.route('/api/commands/pending', methods=['GET'])
def get_pending_command():
player_id = request.args.get('player_id')
world_id = request.args.get('world_id')
_log.warning(f"[poll] player_id={player_id!r} world_id={world_id!r}")
if not player_id:
return jsonify({'error': 'no player_id provided'}), 400
conn = get_db()
c = conn.cursor()
# Free up stuck 'executing' commands (e.g. if the game page was refreshed mid-execution)
two_minutes_ago = (datetime.utcnow() - timedelta(minutes=2)).isoformat()
c.execute('''
UPDATE commands
SET status = 'pending', result_msg = 'Requeued (timeout)'
WHERE status = 'executing' AND updated_at < ? AND player_id = ?
''', (two_minutes_ago, player_id))
build_cmds = _fetch_pending_builds_all_towns(c, player_id, world_id) # one per town
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id, world_id)
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id, world_id)
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id, world_id)
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id, world_id)
research_cmd = _fetch_pending_of_type(c, 'research', player_id, world_id)
culture_cmd = _fetch_pending_culture(c, player_id, world_id) # reads culture_queue
sync_req = _check_and_reset_sync(c, player_id)
# Determine player_key for world-specific settings if world_id is provided
player_key = f"{player_id}_{world_id}" if world_id else player_id
# Farm settings
farm_row = c.execute(
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_key,)
).fetchone()
farm_settings = {
'enabled': bool(farm_row['enabled']) if farm_row else False,
'loot_option': farm_row['loot_option'] if farm_row else 1
}
# Bot settings (bootcamp + rural trade)
bot_row = c.execute(
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
).fetchone()
bot_settings = {
'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False,
'bootcamp_use_def': bool(bot_row['bootcamp_use_def']) if bot_row else False,
'rural_trade_enabled': bool(bot_row['rural_trade_enabled']) if bot_row else False,
'rural_trade_ratio': bot_row['rural_trade_ratio'] if bot_row else 3,
}
# One-shot manual attack flag
attack_now_key = f'bootcamp_attack_now_{player_key}'
flag_row = c.execute('SELECT value FROM kv_store WHERE key = ?', (attack_now_key,)).fetchone()
if flag_row and flag_row['value'] == '1':
bot_settings['attack_now'] = True
c.execute("UPDATE kv_store SET value = '0' WHERE key = ?", (attack_now_key,))
else:
bot_settings['attack_now'] = False
# Feature flags — look up this player's authorized features from their clan
member_row = c.execute(
'SELECT features FROM clan_members WHERE player_id = ?', (str(player_id),)
).fetchone()
if member_row and member_row['features']:
enabled_features = [f.strip() for f in member_row['features'].split(',') if f.strip()]
else:
enabled_features = ['farm', 'admin'] # default: all on (backward-compatible)
conn.commit()
conn.close()
return jsonify({
'builds': build_cmds, # list: one build command per town
'recruit': recruit_cmd,
'market': market_cmd,
'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,
'sync_requested': sync_req
})
def _check_and_reset_sync(c, player_id):
key = f'sync_request_{player_id}'
row = c.execute("SELECT value FROM kv_store WHERE key = ?", (key,)).fetchone()
if row and row['value'] == '1':
c.execute("UPDATE kv_store SET value = '0', updated_at = ? WHERE key = ?", (datetime.utcnow().isoformat(), key))
return True
return False
# ------------------------------------------------------------------
# POST /api/sync-request
# Dashboard requests an immediate state update from the client.
# ------------------------------------------------------------------
@api.route('/api/sync-request', methods=['POST'])
def sync_request():
player_id = request.args.get('player_id')
if not player_id:
return jsonify({'error': 'no player_id provided'}), 400
conn = get_db()
conn.execute('''
INSERT INTO kv_store (key, value, updated_at)
VALUES (?, '1', ?)
ON CONFLICT(key) DO UPDATE SET value = '1', updated_at = excluded.updated_at
''', (f'sync_request_{player_id}', datetime.utcnow().isoformat()))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ------------------------------------------------------------------
# POST /api/commands/<id>/result
# Tampermonkey reports back whether the command succeeded or failed.
# ------------------------------------------------------------------
@api.route('/api/commands/<int:cmd_id>/result', methods=['POST'])
def command_result(cmd_id):
data = request.get_json(silent=True) or {}
status = data.get('status', 'done') # 'done' | 'failed' | 'pending' (requeue)
msg = data.get('message', '')
now = datetime.utcnow().isoformat()
conn = get_db()
# Look up type + player_id for post-update hooks
cmd = conn.execute(
'SELECT type, player_id FROM commands WHERE id = ?', (cmd_id,)
).fetchone()
conn.execute('''
UPDATE commands
SET status = ?, result_msg = ?, updated_at = ?
WHERE id = ?
''', (status, msg, now, cmd_id))
# When an explicit farm_loot command succeeds, record the timestamp
if cmd and cmd['type'] == 'farm_loot' and status == 'done' and cmd['player_id']:
town_id = str(cmd['town_id'])
world_id = town_id.split('_')[1] if '_' in town_id else None
lf_key = f'last_farmed_{cmd["player_id"]}_{world_id}' if world_id else f'last_farmed_{cmd["player_id"]}'
conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
''', (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.
# Body: { detected: true | false }
# ------------------------------------------------------------------
@api.route('/api/captcha/alert', methods=['POST'])
def captcha_alert():
player_id = request.args.get('player_id')
world_id = request.args.get('world_id', '').strip()
if not player_id:
return jsonify({'error': 'no player_id provided'}), 400
data = request.get_json(silent=True) or {}
detected = bool(data.get('detected', False))
# Key is world-specific so captcha in world A doesn't affect world B
kv_key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
conn = get_db()
conn.execute('''
INSERT INTO kv_store (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
''', (kv_key, '1' if detected else '0', datetime.utcnow().isoformat()))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ------------------------------------------------------------------
# POST /api/market_data
# Tampermonkey uploads the market scan data.
# ------------------------------------------------------------------
@api.route('/api/market_data', methods=['POST'])
def upload_market_data():
player_id = request.args.get('player_id')
if not player_id:
return jsonify({'error': 'no player_id provided'}), 400
data = request.get_json(silent=True) or {}
kv_key = f'market_data_{player_id}'
conn = get_db()
conn.execute('''
INSERT INTO kv_store (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
''', (kv_key, json.dumps(data), datetime.utcnow().isoformat()))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ------------------------------------------------------------------
# POST/GET /api/farm_status — TM reports warehouse_full; dashboard reads it
# ------------------------------------------------------------------
@api.route('/api/farm_status', methods=['POST', 'GET'])
def farm_status():
player_id = request.args.get('player_id')
world_id = request.args.get('world_id')
if not player_id:
return jsonify({'error': 'no player_id'}), 400
player_key = f"{player_id}_{world_id}" if world_id else player_id
kv_key = f'farm_status_{player_key}'
conn = get_db()
if request.method == 'POST':
data = request.get_json(silent=True) or {}
now = datetime.utcnow().isoformat()
conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
''', (kv_key, json.dumps(data), now))
# Auto-farm reports warehouse_full=false when it successfully looted something
if not data.get('warehouse_full', True):
conn.execute('''
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
''', (f'last_farmed_{player_key}', now, now))
conn.commit()
conn.close()
return jsonify({'ok': True})
else:
row = conn.execute('SELECT value FROM kv_store WHERE key=?', (kv_key,)).fetchone()
conn.close()
return jsonify(json.loads(row['value']) if row else {'warehouse_full': False})
# ------------------------------------------------------------------
# POST /api/bot-logs
# TM bot reports log entries for bootcamp / rural_trade loops.
# ------------------------------------------------------------------
@api.route('/api/bot-logs', methods=['POST'])
def api_bot_logs():
clan = _get_clan_from_request()
if not clan:
return make_response('Unauthorized', 403)
data = request.get_json(silent=True) or {}
player_id = str(data.get('player_id', ''))
world_id = str(data.get('world_id', ''))
feature = data.get('feature', '')
message = data.get('message', '')
if not player_id or not feature or not message:
return jsonify({'error': 'missing fields'}), 400
player_key = f"{player_id}_{world_id}" if world_id else player_id
conn = get_db()
conn.execute(
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
(player_key, feature, message)
)
# Keep only latest 50 per player/feature
conn.execute('''
DELETE FROM bot_logs
WHERE player_id = ? AND feature = ?
AND id NOT IN (
SELECT id FROM bot_logs
WHERE player_id = ? AND feature = ?
ORDER BY id DESC LIMIT 50
)
''', (player_key, feature, player_key, feature))
conn.commit()
conn.close()
return jsonify({'ok': True})
# ------------------------------------------------------------------
# POST /api/culture/result/<id>
# Bot reports success/failure of a culture command.
# Completely separate from /api/commands/<id>/result — zero
# interference with builds, recruits, or any other command type.
# ------------------------------------------------------------------
@api.route('/api/culture/result/<int:queue_id>', methods=['POST'])
def culture_result(queue_id):
data = request.get_json(silent=True) or {}
status_in = data.get('status', 'done') # 'done' | 'failed'
msg = data.get('message', '')
now = datetime.utcnow().isoformat()
final_status = 'done' if status_in == 'done' else 'failed'
conn = get_db()
row = conn.execute(
'SELECT * FROM culture_queue WHERE id = ?', (queue_id,)
).fetchone()
if row:
conn.execute('''
UPDATE culture_queue
SET status = ?, result_msg = ?, executed_at = ?
WHERE id = ?
''', (final_status, msg, now, queue_id))
# Sync the most recent matching pending culture_log entry
log_row = conn.execute('''
SELECT id FROM culture_log
WHERE player_id = ? AND world_id = ? AND town_id = ?
AND celebration_type = ? AND status = 'pending'
ORDER BY id DESC LIMIT 1
''', (row['player_id'], row['world_id'], row['town_id'], row['celebration_type'])).fetchone()
if log_row:
log_status = 'success' if final_status == 'done' else 'failed'
conn.execute('''
UPDATE culture_log
SET status = ?, result_msg = ?, confirmed_at = ?
WHERE id = ?
''', (log_status, msg, now, log_row['id']))
conn.commit()
conn.close()
return jsonify({'ok': True})
# Serves the modular bot code concatenated into a single response
# ------------------------------------------------------------------
@api.route('/api/bot', methods=['GET'])
def serve_bot():
# Require a valid clan key — reject unknown clients
clan = _get_clan_from_request()
if not clan:
return make_response('Unauthorized: invalid or missing clan key', 403)
bot_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bot_modules')
if not os.path.exists(bot_dir):
return make_response("Bot modules directory not found", 404)
modules = sorted([f for f in os.listdir(bot_dir) if f.endswith('.js')])
combined_code = []
combined_code.append("(function() {")
combined_code.append(" 'use strict';\n")
for module in modules:
with open(os.path.join(bot_dir, module), 'r', encoding='utf-8') as f:
combined_code.append(f" // --- BEGIN {module} ---")
combined_code.append(f.read())
combined_code.append(f" // --- END {module} ---\n")
combined_code.append("})();")
response = make_response("\n".join(combined_code))
response.headers['Content-Type'] = 'application/javascript'
# Prevent caching so updates are instant
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
return response