diff --git a/blueprint_engine.py b/blueprint_engine.py index bdce7e0..ebd9a16 100644 --- a/blueprint_engine.py +++ b/blueprint_engine.py @@ -4,6 +4,11 @@ from datetime import datetime, timedelta log = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# STANDARD_BLUEPRINT — ordered list of phases. +# Each phase is a dict of { building_name: required_level }. +# The engine works through phases in order, queueing one building at a time. +# --------------------------------------------------------------------------- STANDARD_BLUEPRINT = [ {"barracks": 1, "farm": 3, "lumber": 2, "stoner": 2, "ironer": 2, "storage": 2, "main": 2, "temple": 1}, {"barracks": 1, "farm": 3, "lumber": 3, "stoner": 3, "ironer": 3, "storage": 6, "main": 8}, @@ -42,6 +47,9 @@ RESEARCH_LEVELS = { "colonize_ship": 13 } +MAX_LOOKAHEAD_PHASES = 2 # How many phases ahead to look if current phase is fully blocked + + def evaluate_blueprints(conn): blueprints = conn.execute('SELECT town_id, blueprint_name FROM town_blueprints WHERE is_active = 1').fetchall() log.warning(f"[blueprint] Active blueprints: {len(blueprints)}") @@ -57,10 +65,10 @@ def evaluate_blueprints(conn): ).fetchone() if not town_row: - log.warning(f"[blueprint] No town_state row found for town_id={town_id} — skipping") + log.warning(f"[blueprint] No town_state row for town_id={town_id} — skipping") continue - player_id = town_row['player_id'] + player_id = town_row['player_id'] town_name_db = town_row['town_name'] town_world_id = town_row['world_id'] log.warning(f"[blueprint] Town: {town_name_db}, player_id={player_id}, world_id={town_world_id!r}") @@ -68,43 +76,49 @@ def evaluate_blueprints(conn): try: town = json.loads(town_row['data']) except Exception as e: - log.warning(f"[blueprint] Failed to parse town data JSON: {e}") + log.warning(f"[blueprint] Failed to parse town JSON: {e}") continue build_queue = town.get('buildingOrder', []) - buildings = town.get('buildings', {}) - build_data = town.get('buildData', {}) + buildings = town.get('buildings', {}) + build_data = town.get('buildData', {}) - log.warning(f"[blueprint] buildings keys: {list(buildings.keys())}") - log.warning(f"[blueprint] buildData keys: {list(build_data.keys())}") log.warning(f"[blueprint] buildingOrder length: {len(build_queue)}") - # Don't queue anything if there's already a valid pending/executing command in DB. - # However, if a pending command has been sitting untouched for >5 minutes it's - # a ghost (inserted by an old broken blueprint run) — delete it and re-evaluate. + # ── Guard: don't queue if there's already a pending/executing command ────── + # Ghost detection: if a 'pending' command has sat untouched for >5 min, + # it's stale — delete it so we can re-evaluate fresh. five_min_ago = (datetime.utcnow() - timedelta(minutes=5)).isoformat() db_pending = conn.execute( - "SELECT id, type, status, updated_at FROM commands WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')", + "SELECT id, type, status, updated_at FROM commands " + "WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')", (town_id,) ).fetchall() if db_pending: - ghost_ids = [r['id'] for r in db_pending if r['status'] == 'pending' and (r['updated_at'] is None or r['updated_at'] < five_min_ago)] + ghost_ids = [ + r['id'] for r in db_pending + if r['status'] == 'pending' and (r['updated_at'] is None or r['updated_at'] < five_min_ago) + ] if ghost_ids: - log.warning(f"[blueprint] Deleting {len(ghost_ids)} ghost pending commands {ghost_ids} for town {town_id}") - conn.execute(f"DELETE FROM commands WHERE id IN ({','.join('?' for _ in ghost_ids)})", ghost_ids) + log.warning(f"[blueprint] Deleting {len(ghost_ids)} ghost commands {ghost_ids}") + conn.execute( + f"DELETE FROM commands WHERE id IN ({','.join('?' for _ in ghost_ids)})", + ghost_ids + ) conn.commit() db_pending = conn.execute( - "SELECT id, type, status, updated_at FROM commands WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')", + "SELECT id, type, status, updated_at FROM commands " + "WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')", (town_id,) ).fetchall() if db_pending: details = [(r['id'], r['type'], r['status']) for r in db_pending] - log.warning(f"[blueprint] Already has {len(db_pending)} pending/executing commands — skipping. Commands: {details}") + log.warning(f"[blueprint] Already has {len(db_pending)} queued commands — skipping. {details}") continue - # Calculate Future Levels based on current + game queue + # ── Calculate future levels: current buildings + anything in the game build queue ── future_levels = {k: v for k, v in buildings.items()} for q_item in build_queue: b_type = q_item.get('building_type') or q_item.get('name') @@ -113,76 +127,60 @@ def evaluate_blueprints(conn): log.warning(f"[blueprint] future_levels: {future_levels}") - # Find next required building + # ── Simple phase search ──────────────────────────────────────────────────── + # Strategy: + # 1. Find the first phase that has at least one building below its target. + # 2. Queue the first incomplete building from that phase — no resource checks, + # no dependency checks, exactly like adding it manually. + # 3. If every incomplete building in that phase is flagged has_max_level=True + # (meaning the game truly refuses to build it), look up to MAX_LOOKAHEAD_PHASES + # phases ahead for something to queue instead. target_building = None - phase_incomplete = False - blocked_phases = 0 + lookahead_used = 0 + first_incomplete_phase_idx = None for phase_idx, phase in enumerate(STANDARD_BLUEPRINT): - incomplete_buildings = [] - for b_name, req_level in phase.items(): - if future_levels.get(b_name, 0) < req_level: - incomplete_buildings.append(b_name) + # Collect buildings that still need work in this phase + incomplete = [b for b, req in phase.items() if future_levels.get(b, 0) < req] - if incomplete_buildings: - phase_incomplete = True - log.warning(f"[blueprint] Phase {phase_idx} is incomplete. Missing: {incomplete_buildings}") - waiting_for_resources = False + if not incomplete: + continue # Phase is fully complete, move on - for b_name in incomplete_buildings: - b_info = build_data.get(b_name) - if b_info is None: - log.warning(f"[blueprint] {b_name}: no buildData entry — skipping") - continue - has_max = b_info.get('has_max_level', False) - deps = b_info.get('missing_dependencies') - - # Do not trust the frontend 'enough_resources' flag. Grepolis lazily loads this, - # so for background towns it is almost always stale (False). We calculate it manually! - enough = b_info.get('enough_resources') - res = town.get('resources') - if res: - b_wood = b_info.get('wood', 0) - b_stone = b_info.get('stone', 0) - b_iron = b_info.get('iron', 0) - b_pop = b_info.get('pop', 0) - - enough = ( - res.get('wood', 0) >= b_wood and - res.get('stone', 0) >= b_stone and - res.get('iron', 0) >= b_iron and - res.get('population', 0) >= b_pop - ) + # Track the very first incomplete phase we encounter + if first_incomplete_phase_idx is None: + first_incomplete_phase_idx = phase_idx - log.warning(f"[blueprint] {b_name}: has_max={has_max}, deps={deps}, enough_resources={enough}") + log.warning(f"[blueprint] Phase {phase_idx} incomplete: {incomplete}") - if not has_max: - if not deps: - if enough != False: - target_building = b_name - log.warning(f"[blueprint] -> SELECTED {b_name}") - break - else: - log.warning(f"[blueprint] -> waiting for resources for {b_name}") - waiting_for_resources = True + # Separate into: genuinely blocked (has_max=True) vs. queueable + blocked = [b for b in incomplete if build_data.get(b, {}).get('has_max_level', False)] + queueable = [b for b in incomplete if b not in blocked] - if target_building: - break - elif waiting_for_resources: - log.warning(f"[blueprint] Phase {phase_idx}: blocked by resources, stopping lookahead") - break - else: - log.warning(f"[blueprint] Phase {phase_idx}: all blocked by deps, looking ahead (blocked_phases={blocked_phases+1})") - blocked_phases += 1 - if blocked_phases > 2: - log.warning(f"[blueprint] Too many blocked phases, giving up") - break + if queueable: + # We are in the current phase (or a valid lookahead phase) — queue it! + target_building = queueable[0] + log.warning(f"[blueprint] -> SELECTED '{target_building}' from phase {phase_idx}" + + (f" (lookahead +{lookahead_used})" if lookahead_used else "")) + break - # Handle Academy Tech Research + # Everything incomplete in this phase is has_max_level blocked. + # Allow limited lookahead. + if phase_idx == first_incomplete_phase_idx or lookahead_used < MAX_LOOKAHEAD_PHASES: + if phase_idx != first_incomplete_phase_idx: + lookahead_used += 1 + log.warning(f"[blueprint] Phase {phase_idx}: all incomplete buildings are has_max — " + f"looking ahead (lookahead_used={lookahead_used})") + continue + + # Ran out of lookahead budget — stop + log.warning(f"[blueprint] Lookahead exhausted after {lookahead_used} extra phases — giving up") + break + + # ── Academy Research (fallback when no building target) ─────────────────── target_research = None if not target_building: academy_level = future_levels.get('academy', 0) - researched = town.get('researches', {}) + researched = town.get('researches', {}) for r_name in RESEARCH_LIST: if not researched.get(r_name): req_level = RESEARCH_LEVELS.get(r_name, 99) @@ -193,29 +191,31 @@ def evaluate_blueprints(conn): log.warning(f"[blueprint] Final: target_building={target_building}, target_research={target_research}") + # ── Insert command ───────────────────────────────────────────────────────── + now = datetime.utcnow().isoformat() + if target_building: - now = datetime.utcnow().isoformat() - # Get next position for this town's build queue pos_row = conn.execute( "SELECT MAX(position) as max_pos FROM commands" " WHERE player_id = ? AND town_id = ? AND type = 'build'" " AND status IN ('pending', 'executing')", (str(player_id), str(town_id)) ).fetchone() - position = (pos_row['max_pos'] or 0) + 1 + position = (pos_row['max_pos'] or 0) + 1 payload_str = json.dumps({"building_id": target_building}) conn.execute(''' INSERT INTO commands (town_id, town_name, type, payload, status, position, created_at, updated_at, player_id) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) ''', (str(town_id), town_name_db, 'build', payload_str, position, now, now, str(player_id))) log.warning(f"[blueprint] Inserted build command: {target_building} for {town_name_db}") + elif target_research: - now = datetime.utcnow().isoformat() payload_str = json.dumps({"research_id": target_research}) conn.execute(''' INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?) ''', (str(town_id), town_name_db, 'research', payload_str, now, now, str(player_id))) log.warning(f"[blueprint] Inserted research command: {target_research} for {town_name_db}") + else: log.warning(f"[blueprint] Nothing to do for {town_name_db}")