801 lines
32 KiB
Python
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
|
|
|