Core Modules API Reference

This section provides technical documentation for the core logic of the Pokemon LLM Agent.

LLM Driver

core.llmdriver

Classes

Functions

backup_save_state()

Copy current save state file to backup. Previous save becomes backup.

Source code in core/llmdriver.py
def backup_save_state():
    """Copy current save state file to backup. Previous save becomes backup."""
    rom_path = get_rom_path()
    rom_dir = os.path.dirname(rom_path)
    rom_name = os.path.splitext(os.path.basename(rom_path))[0]

    save_file = os.path.join(rom_dir, f"{rom_name}.ss1")
    backup_file = os.path.join(rom_dir, f"{rom_name}-backup.ss1")

    if os.path.exists(save_file):
        try:
            shutil.copy2(save_file, backup_file)
            log.info(f"📦 Backup created: {backup_file}")
            return True
        except Exception as e:
            log.error(f"Failed to create backup: {e}")
            return False
    return False

encode_image_base64(image_path)

Reads an image file and returns its base64 encoded string.

Source code in core/llmdriver.py
def encode_image_base64(image_path: str) -> str | None:
    """Reads an image file and returns its base64 encoded string."""
    if not os.path.exists(image_path) or os.path.getsize(image_path) == 0:
        return None
    try:
        with open(image_path, "rb") as f:
            return base64.b64encode(f.read()).decode("utf-8")
    except Exception as e:
        log.error(f"Error reading/encoding image '{image_path}': {e}")
        return None

get_fast_game_state(sock)

Fast fetch of critical state components to check for race conditions (e.g. did we JUST enter a battle or name entry screen?)

Source code in core/llmdriver.py
def get_fast_game_state(sock) -> dict:
    """
    Fast fetch of critical state components to check for race conditions
    (e.g. did we JUST enter a battle or name entry screen?)
    """
    try:
        location = get_location(sock)
        return {
            "menu_state": get_menu_state(sock),
            "name_entry_state": get_name_entry_state(sock),
            "battle_state": get_battle_state(sock),
            "text_state": get_text_state(sock),
            "map_name": location.get("map_name", "Unknown") if location else "Unknown",
        }
    except Exception as e:
        log.error(f"Error getting fast game state: {e}")
        return {}

run_auto_loop(sock, state, broadcast_func, interval=10.0, max_loops=math.inf, benchmark=None, persistence=None, run_state=None, mgba_proc=None) async

Main async loop: Get state, call LLM, send action, update/broadcast state.

Parameters:

Name Type Description Default
mgba_proc Optional[Any]

The mGBA subprocess - needed for auto-restart on failures for 24/7 operation.

None
Source code in core/llmdriver.py
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
async def run_auto_loop(
    sock,
    state: dict,
    broadcast_func,
    interval: float = 10.0,
    max_loops=math.inf,
    benchmark: Optional[Benchmark] = None,
    persistence=None,
    run_state=None,
    mgba_proc: Optional[Any] = None,
):
    """Main async loop: Get state, call LLM, send action, update/broadcast state.

    Args:
        mgba_proc: The mGBA subprocess - needed for auto-restart on failures for 24/7 operation.
    """
    global \
        action_count, \
        tokens_used_session, \
        start_time, \
        SCREENSHOT_PATH, \
        MINIMAP_PATH, \
        SAVED_SCREENSHOT_PATH, \
        SAVED_MINIMAP_PATH

    cycle_count = 0

    # Track if we're resuming a run - don't increment cycle on first iteration
    # because the restored cycle was interrupted and should be completed first
    is_first_cycle_after_continue = False

    # Restore state from persistence if available
    if run_state and run_state.action_count > 0:
        cycle_count = run_state.cycle_count
        action_count = run_state.action_count
        tokens_used_session = run_state.tokens_used
        # Restore elapsed time by adjusting start_time
        start_time = datetime.datetime.now() - datetime.timedelta(
            seconds=run_state.elapsed_seconds
        )
        # Restore chat history logic deferred
        restored_history = (
            run_state.chat_history if run_state and run_state.chat_history else []
        )
        if restored_history:
            log.info(
                f"🔄 Found persistent chat history: {len(restored_history)} messages"
            )

        log.info(
            f"🔄 Restored from persistence: cycle={cycle_count}, actions={action_count}, tokens={tokens_used_session}"
        )
        # Flag to skip cycle increment on first loop iteration
        is_first_cycle_after_continue = True

    # Initialize memory manager - persist memories when continuing a run
    is_continuing_run = run_state and run_state.action_count > 0
    memory_manager = MemoryManager(reset_on_start=not is_continuing_run)
    if is_continuing_run:
        log.info("📝 Memory manager: Loaded existing memories (continuing run)")
    else:
        log.info("📝 Memory manager: Fresh start (new run)")

    # Initialize goal tracker
    goal_tracker = GoalTracker()
    if not goal_tracker.goals:
        goal_tracker.initialize_default_goals()
    log.info("Goal tracker initialized")

    # Initialize exploration tracker - persist when continuing a run
    exploration_tracker = ExplorationTracker(reset_on_start=not is_continuing_run)
    if is_continuing_run:
        log.info(
            f"🗺️ Exploration tracker: Loaded existing data ({len(exploration_tracker.maps)} maps)"
        )
    else:
        log.info("🗺️ Exploration tracker: Fresh start")

    # Initialize achievement tracker for tweet generation milestones
    achievement_tracker = create_achievement_tracker(
        storage_path="data/achievements.json",
        reset_on_start=not is_continuing_run,
    )
    if is_continuing_run:
        log.info(
            f"🏆 Achievement tracker: Loaded {len(achievement_tracker.triggered)} achievements"
        )
    else:
        log.info("🏆 Achievement tracker: Fresh start")

    # Initialize map visit tracker for loop detection
    map_visit_tracker = MapVisitTracker()
    last_map_name = None  # Track previous map for transition detection
    log.info("🗺️ Map visit tracker initialized (loop detection enabled)")

    # Initialize screenshot history tracker for file lifecycle management (cleanup)
    screenshot_history = ScreenshotHistoryTracker(
        snapshot_dir="snapshots", max_history=5
    )
    log.info("📸 Screenshot history tracker initialized (keeping last 5 snapshots)")

    # Initialize coordinate tracker for loop detection and target tracking
    coord_tracker = CoordinateTracker(
        storage_path="data/coordinate_history.json",
        max_history=10,
        reset_on_start=not is_continuing_run,
    )
    log.info(
        f"📍 Coordinate tracker initialized (history: {coord_tracker.get_context_summary()[:100] if coord_tracker.history else 'empty'})"
    )

    # Initialize navigation controller for goal-based navigation
    navigation_controller = NavigationController(
        coord_tracker=coord_tracker,
        memory_manager=memory_manager,
        map_visit_tracker=map_visit_tracker,
        storage_path="data/navigation_state.json",
        reset_on_start=not is_continuing_run,
    )
    log.info(
        f"🧭 Navigation controller initialized ({len(navigation_controller.goal_stack)} goals)"
    )

    # Initialize target tracker for explicit pathfinding destinations
    target_tracker = TargetTracker()
    log.info("🎯 Target tracker initialized for explicit pathfinding")

    # Initialize scenario manager for scripted overrides and goals
    scenario_manager = ScenarioManager(navigation_controller)
    scenario_manager.set_achievement_tracker(achievement_tracker)
    log.info("Scenario manager initialized (with achievement tracking)")

    # ═══════════════════════════════════════════════════════════════════════════
    # PREPLANNED NAMES - Generate rival name at run start for unique each run
    # ═══════════════════════════════════════════════════════════════════════════
    name_planner = get_name_planner()
    starter_planner = get_starter_planner()

    if not is_continuing_run:
        # Fresh run - generate new rival name via LLM
        log.info("Generating preplanned rival name for new run...")
        try:
            rival_name = generate_rival_name_sync(client, MODEL, temperature=0.9)
            name_planner.set_preplanned_rival_name(rival_name, source="llm")
            log.info(f"Preplanned rival name: '{rival_name}' (LLM-generated)")
        except Exception as e:
            log.warning(f"Failed to generate rival name via LLM: {e}")
            # Fallback to random from suggestions
            import random

            fallback_name = random.choice(RIVAL_NAME_SUGGESTIONS)
            name_planner.set_preplanned_rival_name(fallback_name, source="fallback")
            log.info(f"Using fallback rival name: '{fallback_name}'")
    else:
        # Continuing run - restore preplanned names from persistence
        if run_state and run_state.preplanned_names:
            rival_name = run_state.preplanned_names.get("rival_name")
            if rival_name:
                name_planner.set_preplanned_rival_name(rival_name, source="persisted")
                log.info(f"Restored rival name from persistence: '{rival_name}'")

        # Restore starter choice if available
        if run_state and run_state.starter_choice:
            species = run_state.starter_choice.get("species")
            nickname = run_state.starter_choice.get("nickname")
            if species and nickname:
                starter_planner.set_choice(species, nickname)
                # Restore state flags
                starter_planner._starter_obtained = run_state.starter_choice.get(
                    "obtained", False
                )
                starter_planner._nickname_entered = run_state.starter_choice.get(
                    "nicknamed", False
                )
                log.info(
                    f"Restored starter choice from persistence: {species} '{nickname}'"
                )

        # Ensure a name exists even if not persisted
        if not name_planner.rival_name:
            import random

            fallback_name = random.choice(RIVAL_NAME_SUGGESTIONS)
            name_planner.set_preplanned_rival_name(fallback_name, source="continued")
            log.info(f"Continuing run with fallback rival name: '{fallback_name}'")

    # Capture the main event loop for thread-safe callbacks
    # This MUST be done before defining callbacks that will run in executor threads
    loop = asyncio.get_running_loop()

    # Define thread-safe status callback for vision updates (called from executor threads)
    # Uses run_coroutine_threadsafe because LLMController.stream_action runs in ThreadPoolExecutor
    def status_callback(status: str):
        state["processingStatus"] = status
        # Guard against "Event loop is closed" during shutdown
        if loop.is_running():
            try:
                asyncio.run_coroutine_threadsafe(
                    broadcast_func({"processingStatus": status}), loop
                )
            except RuntimeError:
                pass  # Loop closed during call - ignore

    set_status_callback(status_callback)
    log.info("📢 Processing status callback initialized (thread-safe)")

    # Initialize LLM Controller - encapsulates all LLM interaction logic
    # Re-read supports_vision from env since setup_llm_client doesn't return it yet
    import os

    llm_supports_vision_str = os.getenv("LLM_SUPPORTS_VISION", "true").lower()
    llm_supports_vision = llm_supports_vision_str in ("true", "1", "yes", "on")

    llm_config = {
        "mode": CURRENT_MODE,
        "model": MODEL,
        "temperature": TEMPERATURE,
        "max_tokens": MAX_TOKENS,
        "is_local": IS_LOCAL,
        "reasoning_enabled": REASONING_ENABLED,
        "reasoning_effort": REASONING_EFFORT,
        "uses_default_temp": USES_DEFAULT_TEMPERATURE,
        "uses_max_completion_tokens": USES_MAX_COMPLETION_TOKENS,
        "minimap_enabled": MINIMAP_ENABLED,
        "minimap_2d": MINIMAP_2D,
        "cleanup_window": CLEANUP_WINDOW,
        "system_prompt_unsupported": SYSTEM_PROMPT_UNSUPPORTED,
        "supports_reasoning": supports_reasoning,
        "llm_supports_vision": llm_supports_vision,
    }
    controller = LLMController(client, vision_manager, memory_manager, llm_config)
    controller.set_status_callback(status_callback)

    # Vision result callback - broadcasts vision analysis immediately when complete
    # Bundles processingStatus with vision_log for atomic UI update
    def vision_callback(
        vision_text: str, screenshot_b64: str = None, new_status: str = ""
    ):
        log.info(
            f"📸 Broadcasting vision result immediately ({len(vision_text)} chars)"
        )
        log_id = state.get("log_id_counter", 0)
        vision_log = {
            "id": log_id,
            "text": vision_text,
            "is_vision": True,
            "timestamp": int(time.time() * 1000),
            "screenshot_base64": screenshot_b64,
        }
        # Bundle vision_log and processingStatus in ONE broadcast for atomic UI update
        # This ensures the screenshot is revealed exactly when vision analysis completes
        broadcast_payload = {"vision_log": vision_log}
        if new_status:
            state["processingStatus"] = new_status
            broadcast_payload["processingStatus"] = new_status
        # Guard against "Event loop is closed" during shutdown
        if loop.is_running():
            try:
                asyncio.run_coroutine_threadsafe(
                    broadcast_func(broadcast_payload), loop
                )
            except RuntimeError:
                pass  # Loop closed during call - ignore

    controller.set_vision_callback(vision_callback)
    log.info("🤖 LLM Controller initialized")

    # Initialize Twitch chat service (optional - gracefully disabled if not configured)
    twitch_service = create_twitch_service()
    if twitch_service.is_available:
        try:
            await twitch_service.start()
            log.info("📺 Twitch chat service started")
        except Exception as e:
            log.warning(f"Failed to start Twitch chat service: {e}")
    else:
        log.info(
            "📺 Twitch chat service not configured (set TWITCH_* env vars to enable)"
        )

    # Initialize Pump.fun chat service (optional - for crypto livestream integration)
    pumpfun_service = create_pumpfun_service()
    if pumpfun_service.is_available:
        try:
            await pumpfun_service.start()
            log.info("💎 Pump.fun chat service started")
        except Exception as e:
            log.warning(f"Failed to start Pump.fun chat service: {e}")
    else:
        log.info(
            "💎 Pump.fun chat not configured (set PUMPFUN_TOKEN_ADDRESS to enable)"
        )

    # Initialize Zora chat service (optional - for onchain livestream integration)
    zora_chat_service = create_zora_chat_service()
    if zora_chat_service.is_available:
        try:
            await zora_chat_service.start()
            log.info("🟣 Zora chat service started")
        except Exception as e:
            log.warning(f"Failed to start Zora chat service: {e}")
    else:
        log.info("🟣 Zora chat not configured (set ZORA_USERNAME to enable)")

    # Initialize Zora Poster service (for minting achievement coins)
    zora_poster_service = get_zora_poster_service()
    try:
        await zora_poster_service.start()
        log.info("🖼️ Zora poster service started")
    except Exception as e:
        log.warning(f"Failed to start Zora poster service: {e}")

    # Initialize specialized trackers
    screenshot_manager = get_screenshot_manager()
    zora_achievement_tracker = get_zora_achievement_tracker()

    # Pre-fetch Zora post counter if needed (poster service does this internally or we can do it here)
    # The tracker handles it on init/load.

    # Initialize ComfyUI TTS service (optional - gracefully disabled if not configured)
    # Create callback to notify UI when TTS starts playing (for synchronized typewriter)
    async def on_tts_playback_start(
        text: str, duration_ms: int, metadata: Dict[str, Any] = None
    ):
        """Called when TTS audio is about to start playing. Broadcasts to UI for sync."""
        log.info(
            f"🔊 Broadcasting TTS playback start: {len(text)} chars, {duration_ms}ms, meta={bool(metadata)}"
        )
        try:
            payload = {
                "tts_commentary": {
                    "text": text,
                    "duration_ms": duration_ms,
                    "playing": True,
                }
            }
            # Inject metadata if present (e.g. reply_to info)
            if metadata:
                payload["tts_commentary"].update(metadata)

            await broadcast_func(payload)
        except Exception as e:
            log.error(f"Failed to broadcast TTS start: {e}")

    tts_service = create_tts_service(on_playback_start=on_tts_playback_start)
    if tts_service.is_available:
        is_connected = await tts_service.check_connection()
        if is_connected:
            log.info(f"🔊 ComfyUI TTS service connected: {tts_service.base_url}")
        else:
            log.warning(
                f"🔊 ComfyUI TTS not reachable at {tts_service.base_url} (will retry when needed)"
            )
    else:
        log.info(
            "🔊 ComfyUI TTS service not configured (set COMFYUI_URL in .env to enable)"
        )

    # Initialize Chat Response service (uses Featherless AI / Alkahest for chat responses)
    chat_response_service = create_chat_response_service()
    if chat_response_service.is_available:
        log.info(f"💬 Chat response service configured: {chat_response_service.model}")
    else:
        log.info(
            "💬 Chat response service not configured (set LLM_CHAT_PROVIDER and keys to enable)"
        )

    # Initialize Tweet Generator service (Discord approval + X posting during intro)
    tweet_generator = create_tweet_generator(llm_client=client)
    if tweet_generator.enabled:
        log.info("🐦 Tweet generator enabled (Discord approval + X posting)")

        # Set up photo callback for scripted photo moments
        def on_photo_moment(achievement):
            """Callback when a scripted photo moment is triggered."""

            async def trigger_photo_tweet():
                try:
                    outcome = await tweet_generator.generate_and_post_achievement_tweet(
                        achievement=achievement,
                        run_state=run_state,
                        game_state=state,
                        screenshot_path=SAVED_SCREENSHOT_PATH,  # Include game screenshot
                    )
                    if outcome.result == TweetGeneratorResult.POSTED:
                        achievement_tracker.mark_tweet_posted(
                            achievement.achievement_type,
                            outcome.tweet_url,
                        )
                        log.info(f"📸 Photo tweet posted: {outcome.tweet_url}")
                except Exception as e:
                    log.error(f"Photo tweet generation failed: {e}")

            asyncio.create_task(trigger_photo_tweet())

        scenario_manager.set_photo_callback(on_photo_moment)
    else:
        log.info(
            "🐦 Tweet generator disabled (set TWITTER_ENABLED and DISCORD_ENABLED to enable)"
        )

    # Helper function to generate a chat response via the dedicated chat LLM
    async def generate_chat_response(
        username: str, message: str, is_past: bool = False
    ) -> str:
        """Generate a response to a Twitch chat message using the chat response service."""
        # Get chatter memory context if available
        chatter_context = ""
        if twitch_service.is_available and hasattr(twitch_service, "engagement"):
            chatter_context = twitch_service.engagement.memory.get_context(username)

        if chat_response_service.is_available:
            return await chat_response_service.generate_response(
                username, message, is_past, chatter_context
            )

        # Fallback to main LLM if chat service not available
        try:
            prompt = get_chat_response_prompt(username, message, is_past)
            response = client.chat.completions.create(
                model=MODEL,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=150,
                temperature=0.9,
            )
            if response.choices and response.choices[0].message.content:
                return response.choices[0].message.content.strip()
            return ""
        except Exception as e:
            log.error(f"Error generating chat response: {e}")
            return ""

    # Position history for stuck detection
    position_history = []

    # Track last action for failure replay
    last_action = None
    last_position = None

    b64_mm = None

    # Persistence save interval (save every N cycles)
    PERSIST_INTERVAL = 5
    cycles_since_persist = 0

    # Track previous cycle time for UI display
    prev_cycle_time_s = 0.0
    cycle_times_history = []  # List of recent cycle times for average calculation

    # mGBA timeout - if no response in this time, restart the cycle
    # INCREASED from 5s to 15s - screenshot capture can take 4+ seconds after restart
    # and thread race conditions caused false timeouts when old threads kept running
    MGBA_TIMEOUT = 35  # seconds - must exceed socket timeout (30s) to let prep_llm complete/fail naturally

    benchInstructions = ""
    if benchmark is not None:
        benchInstructions = benchmark.instructions
        logging.info(f"Added bench instructions: {benchInstructions}")

    # Always use fresh system prompt (in case prompts.py was updated)
    fresh_system_prompt = build_system_prompt("", benchInstructions)

    if run_state and run_state.chat_history:
        # Get the old system prompt from persisted history
        old_system_prompt = ""
        if run_state.chat_history and run_state.chat_history[0].get("role") == "system":
            old_system_prompt = run_state.chat_history[0].get("content", "")

        # Check if prompt format has changed by looking for key structural changes
        # We check for the COMMENTARY section format which changed from section 8/9 to section 7
        old_has_commentary_7 = (
            "7. COMMENTARY" in old_system_prompt and "REQUIRED" in old_system_prompt
        )
        new_has_commentary_7 = (
            "7. COMMENTARY" in fresh_system_prompt and "REQUIRED" in fresh_system_prompt
        )
        prompt_format_changed = old_has_commentary_7 != new_has_commentary_7

        if prompt_format_changed:
            # Prompt format changed - start fresh to avoid LLM following old patterns
            log.info(
                "🔄 PROMPT FORMAT CHANGED - Clearing chat history to adopt new format"
            )
            controller.chat_history = [
                {"role": "system", "content": fresh_system_prompt}
            ]
        else:
            # Restore chat history but replace the system prompt with fresh one
            controller.chat_history = restored_history
            if (
                controller.chat_history
                and controller.chat_history[0].get("role") == "system"
            ):
                controller.chat_history[0] = {
                    "role": "system",
                    "content": fresh_system_prompt,
                }
                log.info("🔄 Updated system prompt to latest version")
    else:
        controller.chat_history = [{"role": "system", "content": fresh_system_prompt}]

    # Track consecutive mGBA failures across cycle retries
    # This persists across loop iterations so we can detect when mGBA is completely dead
    consecutive_mgba_failures = 0

    # State tracking for Twitch Predictions
    was_in_battle = False

    # Reduced to 3 - mGBA Lua socket callbacks may occasionally timeout
    # but if it fails 3 times in a row, it's likely frozen
    MAX_CONSECUTIVE_FAILURES = 3  # Restart mGBA after 3 consecutive failures
    RECONNECT_THRESHOLD = 3  # Skip socket reconnection - go straight to restart

    # Use mutable containers for socket and process so restarts update all references
    sock_ref = {"socket": sock}
    proc_ref = {"proc": mgba_proc}

    # Socket lock to prevent race condition where old ThreadPoolExecutor threads
    # continue using the socket after asyncio timeout, corrupting data for new threads.
    # ThreadPoolExecutor doesn't cancel running threads on timeout - they keep running!
    socket_lock = threading.Lock()

    def prep_llm_locked(sock):
        """Wrapper that acquires lock before accessing socket.

        This prevents race conditions where an old prep_llm thread (that didn't
        get cancelled by asyncio timeout) corrupts socket data for new calls.
        """
        with socket_lock:
            return prep_llm(sock)

    # Import config for mGBA paths
    import config

    def restart_mgba(port=8888):
        """
        Kill and restart the entire mGBA process for 24/7 autonomous operation.
        This is called when socket reconnection fails - mGBA Lua script is probably frozen.
        """
        nonlocal sock_ref, proc_ref
        log.warning("🔄 RESTARTING MGBA PROCESS (Lua script may be frozen)...")

        # 1. Close old socket
        try:
            sock_ref["socket"].close()
            log.info("🔌 Old socket closed")
        except Exception as e:
            log.warning(f"Error closing old socket: {e}")

        # 2. Kill old process
        if proc_ref["proc"] and proc_ref["proc"].poll() is None:
            try:
                proc_ref["proc"].terminate()
                proc_ref["proc"].wait(timeout=5)
                log.info("💀 Old mGBA process terminated")
            except subprocess.TimeoutExpired:
                log.warning("mGBA didn't terminate gracefully, killing...")
                proc_ref["proc"].kill()
                proc_ref["proc"].wait()
            except Exception as e:
                log.error(f"Error terminating mGBA: {e}")

        # 3. Wait a moment for cleanup
        time.sleep(2)

        # 4. Start new mGBA process
        rom_path = get_rom_path()
        # Construct path to slot 1 save state (e.g. roms/red.gb -> roms/red.ss1)
        # Using CLI load avoids the socket LOADSTATE pause/freeze issue
        import os

        ss1_path = os.path.splitext(rom_path)[0] + ".ss1"

        cmd = [config.MGBA_EXE, "--script", config.LUA_SCRIPT]

        # If save state exists, load it via CLI
        if os.path.exists(ss1_path):
            log.info(f"Using CLI to load save state: {ss1_path}")
            cmd.extend(["-t", ss1_path])
        else:
            log.warning(f"Save state not found at {ss1_path}, starting fresh")

        cmd.append(rom_path)

        log.info(f"Starting new mGBA: {' '.join(cmd)}")
        try:
            proc_ref["proc"] = subprocess.Popen(
                cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
            )
        except Exception as e:
            log.error(f"Failed to start mGBA: {e}")
            return False

        # 5. Wait for mGBA to initialize
        log.info("⏳ Waiting for mGBA to initialize...")
        time.sleep(4)

        # Check if mGBA started successfully
        if proc_ref["proc"].poll() is not None:
            log.error(
                f"mGBA exited immediately with code {proc_ref['proc'].returncode}"
            )
            return False

        # 6. Connect to new socket
        import socket as sock_module

        for attempt in range(5):
            try:
                new_sock = sock_module.create_connection(("localhost", port), timeout=5)
                new_sock.setblocking(True)
                new_sock.settimeout(30.0)
                sock_ref["socket"] = new_sock
                log.info(f"✅ Connected to new mGBA socket (fd={new_sock.fileno()})")
                break
            except Exception as e:
                log.warning(f"Socket connection attempt {attempt + 1}/5 failed: {e}")
                time.sleep(1)
        else:
            log.error("Failed to connect to new mGBA socket after 5 attempts")
            return False

        # 7. Load save state (SKIPPED - handled via CLI to prevent freeze)
        # try:
        #     response = send_command(sock_ref["socket"], "LOADSTATE 1")
        #     if response and "OK" in response:
        #         log.info("✅ Save state loaded successfully!")
        #     else:
        #         log.warning(f"Save state load response: {response}")
        # except Exception as e:
        #     log.error(f"Failed to load save state: {e}")

        # 8. Enable input display
        try:
            send_command(sock_ref["socket"], "INPUT_DISPLAY_ON")
        except:
            pass

        log.info("🎮 MGBA RESTART COMPLETE - Resuming game loop!")
        return True

    def reconnect_socket(port=8888):
        """Attempt to reconnect to mGBA socket. Updates sock_ref in place."""
        nonlocal sock_ref
        log.warning("🔌 Attempting to reconnect to mGBA socket...")
        import socket as sock_module

        try:
            # Close old socket cleanly
            try:
                sock_ref["socket"].close()
            except:
                pass

            # Try to create a new connection
            time.sleep(1)  # Give mGBA time to clean up
            new_sock = sock_module.create_connection(("localhost", port), timeout=5)
            new_sock.setblocking(True)
            new_sock.settimeout(
                30.0
            )  # Set default timeout to prevent indefinite blocking
            sock_ref["socket"] = new_sock  # Update the shared reference
            log.info(
                f"✅ Successfully reconnected to mGBA socket! (new fd={new_sock.fileno()})"
            )
            return True
        except Exception as e:
            log.error(f"❌ Failed to reconnect to mGBA: {e}")
            return False

    def check_socket_health():
        """Quick check if socket is still valid before operations."""
        try:
            fd = sock_ref["socket"].fileno()
            if fd < 0:
                log.warning(f"🔌 Socket fd is invalid ({fd})")
                return False
            return True
        except Exception as e:
            log.warning(f"🔌 Socket health check failed: {e}")
            return False

    # ═══════════════════════════════════════════════════════════════════════════
    # STARTUP COUNTDOWN & INTRO CYCLE
    # Wait for countdown duration (synced with frontend Starting Screen)
    # Generate dynamic intro via LLM, preload TTS, play after chatbox appears
    # ═══════════════════════════════════════════════════════════════════════════

    # Import countdown duration from config
    import config as cfg

    countdown_seconds = cfg.STREAM_COUNTDOWN_SECONDS

    # Get memory context for dynamic intro generation
    memory_context = ""
    if is_first_cycle_after_continue:
        # Get previous session context from memory manager
        try:
            # Get recent narrative memories for context
            recent_memories = memory_manager.get_context_for_llm("")
            if recent_memories:
                memory_context = recent_memories[:500]  # Limit for prompt size
        except Exception as e:
            log.warning(f"Failed to get memory context for intro: {e}")

    # Generate dynamic intro text via LLM
    intro_text = ""
    try:
        if is_first_cycle_after_continue:
            intro_prompt = f"""You are Lass, a young Pokemon trainer streaming your Pokemon Red adventure.
Generate a SHORT (1-2 sentences) stream intro message for returning after connection issues.

Context from your last session:
{memory_context if memory_context else "You were playing Pokemon Red."}

Rules:
- Be energetic and excited to continue
- Reference something specific from memory if possible (location, goal, recent event)
- Keep it under 30 words
- Use casual streaming language like "Hey chat!", "We're back!", etc.
- NO hashtags, NO emotes, NO special formatting

Example: "Hey everyone! Lass is back! Sorry about that, let's see if we can finally make it through Viridian Forest this time!"

Your intro message:"""
        else:
            intro_prompt = """You are Lass, a young Pokemon trainer starting your Pokemon Red stream.
Generate a SHORT (1-2 sentences) stream intro message for starting a new adventure.

Rules:
- Be energetic and excited to start
- Keep it under 30 words  
- Use casual streaming language like "Hey chat!", "Let's go!", etc.
- NO hashtags, NO emotes, NO special formatting

Example: "Hey chat! It's Lass! Welcome to my Pokemon journey! Let's catch 'em all and become the very best!"

Your intro message:"""

        log.info("🎬 Generating dynamic intro text via LLM...")
        response = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": intro_prompt}],
            max_tokens=100,
            temperature=0.9,
        )
        if response.choices and response.choices[0].message.content:
            intro_text = response.choices[0].message.content.strip()
            # Clean up any quotes
            intro_text = intro_text.strip("\"'")
            log.info(f"✅ Generated intro: {intro_text}")
    except Exception as e:
        log.warning(f"Failed to generate dynamic intro: {e}")

    # Fallback if LLM generation failed
    if not intro_text:
        if is_first_cycle_after_continue:
            intro_text = "Hey everyone! Lass is back! Had some connection issues but I'm ready to continue my Pokemon adventure!"
        else:
            intro_text = "Hey chat! It's Lass! Welcome to my Pokemon Red stream! Let's catch some Pokemon and become the very best!"

    # Broadcast that we're in the 'starting' phase with countdown duration FIRST
    # Frontend will use countdownSeconds to set its timer immediately
    await broadcast_func(
        {
            "introPhase": "starting",
            "countdownSeconds": countdown_seconds,
            "processingStatus": "",
        }
    )
    log.info(f"⏱️ Stream countdown started: {countdown_seconds}s")

    # --- TWEET GENERATION DURING COUNTDOWN ---
    # Start tweet generation as a background task - runs Discord approval during countdown
    tweet_task = None
    tweet_outcome = None
    if tweet_generator.enabled:
        log.info("🐦 Starting tweet generation (runs during countdown)...")

        async def run_tweet_generation():
            """Run tweet generation and return outcome."""
            try:
                # Build minimal game state from current state
                game_state = {}
                if state:
                    game_state = {
                        "map_name": state.get("map_name", ""),
                        "badges": state.get("badges", []),
                        "party": state.get("party", []),
                    }

                return await tweet_generator.generate_and_post_tweet(
                    is_continuing_run=is_continuing_run,
                    run_state=run_state,
                    game_state=game_state,
                )
            except Exception as e:
                log.error(f"Tweet generation failed: {e}")
                return None

        # Start as background task - will complete during countdown
        tweet_task = asyncio.create_task(run_tweet_generation())

    # Preload TTS audio DURING countdown (synthesize but don't play yet)
    # Run this in background while countdown is active
    preloaded_audio_path = None
    if tts_service and tts_service.is_available:
        # Clear any orphaned TTS requests from previous session
        await tts_service.clear_queue()

        log.info(f"🎬 Preloading intro TTS during countdown...")
        try:
            # Synthesize but don't play - returns the audio file path
            preloaded_audio_path = await tts_service.synthesize_only(intro_text)
            if preloaded_audio_path:
                log.info(f"✅ Intro TTS preloaded: {preloaded_audio_path}")
            else:
                log.warning("⚠️ TTS preload returned None, will synthesize on-demand")
        except Exception as e:
            log.warning(f"⚠️ TTS preload failed, will synthesize on-demand: {e}")

    # --- PRE-LOAD FIRST CYCLE DURING COUNTDOWN ---
    # While the intro TTS and countdown are happening, we run the first cycle analysis
    # so it's ready to display immediately when we transition to the game screen
    log.info("🎮 Pre-loading first cycle analysis during countdown...")
    first_cycle_vision = None
    first_cycle_analysis = None
    first_cycle_tts_path = None

    try:
        # Take screenshot of paused game (mGBA is paused but still provides screenshots)
        await asyncio.sleep(0.5)  # Let mGBA settle
        loop = asyncio.get_event_loop()
        current_socket = sock_ref["socket"]

        try:
            first_cycle_state = await asyncio.wait_for(
                loop.run_in_executor(None, prep_llm_locked, current_socket), timeout=20
            )
        except asyncio.TimeoutError:
            log.warning("⚠️ First cycle prep_llm timed out - will run on game start")
            first_cycle_state = None

        if first_cycle_state:
            log.info("📸 Got first cycle screenshot from paused game")

            # Broadcast that we're analyzing vision (even though we're in intro)
            await broadcast_func({"processingStatus": "ANALYZING VISION..."})

            # Run vision analysis on the paused screenshot
            try:
                first_cycle_vision = None
                if vision_manager:
                    vision_result, _ = await vision_manager.analyze_image(
                        SAVED_SCREENSHOT_PATH
                    )
                    first_cycle_vision = vision_result
                if first_cycle_vision:
                    log.info(
                        f"👁️ First cycle vision ready: {first_cycle_vision[:100]}..."
                    )
            except Exception as ve:
                log.warning(f"⚠️ First cycle vision failed: {ve}")

            # Run LLM analysis for first cycle (skipped - would need controller)
            if first_cycle_vision:
                # Note: Full LLM analysis requires controller.call_with_timeout()
                # which is not available during intro. Skipping preload.
                log.info(
                    "📝 First cycle vision ready, LLM analysis will run on game start"
                )

            await broadcast_func({"processingStatus": ""})
    except Exception as e:
        log.warning(f"⚠️ First cycle preload failed: {e}")
        await broadcast_func({"processingStatus": ""})

    # Wait for remaining countdown duration (TTS preload + analysis took time)
    elapsed_for_preload = 20  # Approximate seconds for TTS + first cycle preload
    remaining_countdown = max(0, countdown_seconds - elapsed_for_preload)

    if remaining_countdown > 0:
        # Log progress for longer countdowns
        for elapsed in range(0, remaining_countdown, 30):
            remaining = remaining_countdown - elapsed
            log.info(f"⏱️ Countdown: {remaining}s remaining...")
            await asyncio.sleep(min(30, remaining))

    # Countdown complete! Trigger color transition
    log.info("🎨 Countdown complete! Starting color transition...")
    await broadcast_func({"introPhase": "transitioning"})

    # --- CHECK TWEET GENERATION RESULT ---
    # If tweet task is still running, give it a few more seconds to complete
    if tweet_task and not tweet_task.done():
        log.info("🐦 Waiting for tweet generation to complete...")
        try:
            tweet_outcome = await asyncio.wait_for(tweet_task, timeout=30)
        except asyncio.TimeoutError:
            log.warning("🐦 Tweet generation timed out during transition")
            tweet_task.cancel()
    elif tweet_task:
        # Task already completed
        try:
            tweet_outcome = tweet_task.result()
        except Exception as e:
            log.warning(f"🐦 Tweet task error: {e}")

    # Log tweet outcome
    if tweet_outcome:
        if tweet_outcome.result == TweetGeneratorResult.POSTED:
            log.info(f"🐦 Tweet posted successfully: {tweet_outcome.tweet_url}")
        elif tweet_outcome.result == TweetGeneratorResult.MAX_REGENERATIONS:
            log.info("🐦 Tweet skipped: max regenerations reached")
        elif tweet_outcome.result == TweetGeneratorResult.ERROR:
            log.warning(f"🐦 Tweet error: {tweet_outcome.error}")

    # Wait for color transition animation (~3 seconds on frontend)
    await asyncio.sleep(3)

    # Transition to Just Chatting phase
    # Character slides in over 6s, chatbox appears at 4.8s
    log.info("🎥 Entering Just Chatting phase...")
    await broadcast_func(
        {
            "introPhase": "just-chatting",
            "introText": intro_text,  # Send the dynamic intro text for the chatbox
            "processingStatus": "STARTING STREAM...",
        }
    )

    # Wait for character animation and chatbox to appear (5s)
    log.info("⏳ Waiting for character entrance animation...")
    await asyncio.sleep(5)

    # Broadcast intro text for chatbox typewriter (just-chatting phase)
    await broadcast_func(
        {
            "response_log": {
                "id": 0,
                "text": intro_text,
                "is_response": True,
                "timestamp": int(time.time() * 1000),
            }
        }
    )

    # Play the preloaded intro TTS
    if tts_service and tts_service.is_available:
        try:
            if preloaded_audio_path:
                await tts_service.play_preloaded(preloaded_audio_path, intro_text)
            else:
                await tts_service.synthesize_and_play(
                    intro_text, priority=tts_service.PRIORITY_COMMENTARY, wait=True
                )
            log.info("✅ Intro TTS complete")
        except Exception as e:
            log.warning(f"Intro TTS playback failed: {e}")

    # Clear processing status
    await broadcast_func({"processingStatus": ""})

    # --- TRANSITION TO GAME ---
    # 1. Clear intro text and show first cycle analysis instead
    # 2. Continue game (no restart needed - game was running with audio during intro)
    # 3. Start the session with preloaded first cycle analysis

    log.info("🎮 Transitioning to game screen - first cycle analysis ready!")

    # Broadcast session start with first cycle analysis (clearing intro text)
    session_start_ms = int(time.time() * 1000)
    game_transition_payload = {
        "introPhase": "game",
        "sessionStartTime": session_start_ms,
        "cyclesEnabled": True,
    }

    # If we have first cycle analysis, send it to replace intro text
    if first_cycle_analysis:
        commentary = first_cycle_analysis.get("commentary", "")
        if commentary:
            # This will replace the intro text in the UI
            game_transition_payload["response_log"] = {
                "id": 1,
                "text": commentary,
                "is_response": True,
                "timestamp": session_start_ms,
            }

        # Also update vision analysis if available
        if first_cycle_vision:
            game_transition_payload["vision_analysis"] = first_cycle_vision

        # Update LLM analysis
        llm_text = first_cycle_analysis.get(
            "reasoning", ""
        ) or first_cycle_analysis.get("thoughts", "")
        if llm_text:
            game_transition_payload["llm_analysis"] = llm_text

    await broadcast_func(game_transition_payload)
    log.info(f"📊 Session started at {session_start_ms}")

    # Play first cycle TTS if available
    if first_cycle_tts_path and tts_service and tts_service.is_available:
        try:
            first_commentary = (
                first_cycle_analysis.get("commentary", "")
                if first_cycle_analysis
                else ""
            )
            await tts_service.play_preloaded(first_cycle_tts_path, first_commentary)
            log.info("✅ First cycle TTS played")
        except Exception as e:
            log.warning(f"First cycle TTS playback failed: {e}")

    # Small delay to let frontend complete wipe transition
    await asyncio.sleep(2)

    while action_count < max_loops:
        loop_start_time = time.time()

        # Skip cycle increment on first iteration when continuing a run
        # The restored cycle was interrupted and should be completed first
        if is_first_cycle_after_continue:
            is_first_cycle_after_continue = False
            log.info(f"🔄 Resuming interrupted cycle {cycle_count}")
        else:
            cycle_count += 1
        current_cycle = cycle_count
        log.info(f"--- Loop Cycle {current_cycle} ---")

        # CRITICAL: Always sync cycle/action count to run_state for graceful shutdown
        # This ensures Ctrl+C saves the correct state even between PERSIST_INTERVAL saves
        if run_state:
            run_state.cycle_count = cycle_count
            run_state.action_count = action_count

        # Broadcast cycle count immediately
        update_payload = {"cycle": current_cycle}
        await broadcast_func({"cycle": current_cycle})  # Actually broadcast immediately
        action_payload = {}

        # Performance metrics container
        cycle_metrics = {
            "mGBA": 0.0,
            "vision": 0.0,
            "diff": 0.0,
            "llm": 0.0,
            "cycle": 0.0,
        }

        # Track true cycle start time (persists across restarts within same cycle)
        true_cycle_start = time.time()

        # Initialize result to prevent NameError if loop breaks early
        result = None

        try:
            log.info("Requesting game state from mGBA...")
            t_mgba_start = time.time()

            # Check socket health before attempting prep_llm
            if not check_socket_health():
                log.warning(
                    "🔌 Socket unhealthy before prep_llm, attempting reconnection..."
                )
                if reconnect_socket():
                    log.info("✅ Socket reconnected before prep_llm")
                else:
                    consecutive_mgba_failures += 1
                    if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                        log.warning(
                            f"🔄 mGBA unresponsive after {MAX_CONSECUTIVE_FAILURES} failures - attempting full restart..."
                        )
                        if restart_mgba():
                            consecutive_mgba_failures = (
                                0  # Reset counter after successful restart
                            )
                            log.info("✅ mGBA restarted! Continuing 24/7 operation.")
                        else:
                            log.error(
                                "❌ mGBA restart failed! Waiting 30s before retry..."
                            )
                            await asyncio.sleep(30)
                    cycle_count -= 1
                    await asyncio.sleep(2)
                    continue

            # Wrap prep_llm in async timeout to prevent indefinite blocking
            # Use sock_ref["socket"] to ensure we use the current socket (may be reconnected)
            # Use prep_llm_locked to prevent race condition with old threads
            loop = asyncio.get_event_loop()
            current_socket = sock_ref["socket"]  # Capture current socket for executor
            try:
                current_mGBA_state = await asyncio.wait_for(
                    loop.run_in_executor(None, prep_llm_locked, current_socket),
                    timeout=MGBA_TIMEOUT,
                )
            except asyncio.TimeoutError:
                mgba_duration = time.time() - t_mgba_start
                consecutive_mgba_failures += 1
                log.error(
                    f"⏱️ mGBA TIMEOUT after {mgba_duration:.1f}s (limit: {MGBA_TIMEOUT}s) - Retrying same cycle ({consecutive_mgba_failures}/{MAX_CONSECUTIVE_FAILURES})"
                )
                cycle_metrics["mGBA"] = (
                    mgba_duration * 1000
                )  # Store in ms for consistency

                # Try to reconnect socket after RECONNECT_THRESHOLD failures
                if consecutive_mgba_failures == RECONNECT_THRESHOLD:
                    log.warning(
                        f"🔌 Socket may be dead after {RECONNECT_THRESHOLD} failures. Attempting reconnection..."
                    )
                    if reconnect_socket():
                        log.info("✅ Socket reconnected! Continuing cycle retries...")
                    else:
                        log.error("❌ Socket reconnection failed. Will keep trying...")

                # Check if mGBA is completely dead - trigger restart
                if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                    log.warning(
                        f"🔄 mGBA unresponsive after {MAX_CONSECUTIVE_FAILURES} failures - attempting full restart..."
                    )
                    if restart_mgba():
                        consecutive_mgba_failures = (
                            0  # Reset counter after successful restart
                        )
                        log.info("✅ mGBA restarted! Continuing 24/7 operation.")
                    else:
                        log.error("❌ mGBA restart failed! Waiting 30s before retry...")
                        await asyncio.sleep(30)

                # Decrement cycle_count to retry the same cycle number (it was incremented at loop start)
                cycle_count -= 1
                await asyncio.sleep(2)  # Brief pause before retry
                continue

            mgba_duration = time.time() - t_mgba_start
            cycle_metrics["mGBA"] = mgba_duration * 1000  # Store in ms
            log.info(f"⏱️ mGBA Response: {mgba_duration:.2f}s")

            # Reset failure counter on success
            consecutive_mgba_failures = 0

            if benchmark is not None:
                # check if we complted the bench
                if benchmark.validation(current_mGBA_state):
                    break

            # print(str(current_mGBA_state))
            if not current_mGBA_state:
                consecutive_mgba_failures += 1
                log.error(
                    f"Failed to get state from mGBA (prep_llm returned None). Retrying same cycle ({consecutive_mgba_failures}/{MAX_CONSECUTIVE_FAILURES})."
                )

                if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                    log.warning(f"🔄 mGBA unresponsive - attempting full restart...")
                    if restart_mgba():
                        consecutive_mgba_failures = 0
                        log.info("✅ mGBA restarted! Continuing 24/7 operation.")
                    else:
                        await asyncio.sleep(30)

                cycle_count -= 1  # Retry same cycle number
                await asyncio.sleep(max(0, interval - (time.time() - loop_start_time)))
                continue
            log.info("Received game state from mGBA.")

            # --- ATOMIC SNAPSHOT LOGIC ---
            t_snapshot_start = time.time()
            # Create a unique snapshot for this cycle to prevent race conditions (Vision vs UI sync)
            os.makedirs("snapshots", exist_ok=True)
            snapshot_path = f"snapshots/cycle_{current_cycle}.png"
            # Ensure we are copying the fresh capture
            try:
                shutil.copyfile(SAVED_SCREENSHOT_PATH, snapshot_path)
                SCREENSHOT_PATH = snapshot_path
                log.info(f"🔒 Atomic snapshot locked: {snapshot_path}")

                # Register with history tracker - this keeps N and N-1, auto-cleans N-2+
                screenshot_history.add_screenshot(current_cycle, snapshot_path)

                # Capture for Zora gallery (keeps last 5 for progress posts)
                if screenshot_manager:
                    screenshot_manager.capture(
                        source_path=snapshot_path,
                        cycle_number=current_cycle,
                        action_number=action_count,
                        map_name=current_mGBA_state.get("map_name", ""),
                    )
            except Exception as e:
                log.error(
                    f"Failed to create atomic snapshot: {e}. Falling back to global path."
                )
                SCREENSHOT_PATH = SAVED_SCREENSHOT_PATH

            cycle_metrics["snapshot"] = (time.time() - t_snapshot_start) * 1000
        except socket.timeout:
            consecutive_mgba_failures += 1
            log.error(
                f"Socket timeout getting state from mGBA ({consecutive_mgba_failures}/{MAX_CONSECUTIVE_FAILURES}). Retrying..."
            )

            if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                log.warning(f"🔄 mGBA unresponsive - attempting full restart...")
                if restart_mgba():
                    consecutive_mgba_failures = 0
                else:
                    await asyncio.sleep(30)

            cycle_count -= 1  # Retry same cycle
            await asyncio.sleep(2)  # Brief pause before retry
            continue
        except socket.error as se:
            consecutive_mgba_failures += 1
            log.error(
                f"Socket error getting state from mGBA: {se} ({consecutive_mgba_failures}/{MAX_CONSECUTIVE_FAILURES}). Retrying..."
            )

            if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                log.warning(f"🔄 mGBA unresponsive - attempting full restart...")
                if restart_mgba():
                    consecutive_mgba_failures = 0
                else:
                    await asyncio.sleep(30)

            cycle_count -= 1  # Retry same cycle
            await asyncio.sleep(2)  # Brief pause before retry
            continue
        except Exception as e:
            consecutive_mgba_failures += 1
            log.error(
                f"Error getting state from mGBA: {e} - Retrying same cycle ({consecutive_mgba_failures}/{MAX_CONSECUTIVE_FAILURES})",
                exc_info=True,
            )

            if consecutive_mgba_failures >= MAX_CONSECUTIVE_FAILURES:
                log.warning(f"🔄 mGBA unresponsive - attempting full restart...")
                if restart_mgba():
                    consecutive_mgba_failures = 0
                else:
                    await asyncio.sleep(30)

            cycle_count -= 1  # Retry same cycle number
            await asyncio.sleep(max(0, interval - (time.time() - loop_start_time)))
            continue

        t_deepcopy_start = time.time()
        llm_input_state = copy.deepcopy(current_mGBA_state)
        cycle_metrics["deepcopy"] = (time.time() - t_deepcopy_start) * 1000

        # Initialize minimap_analysis to None (will be set later if minimap data exists)
        minimap_analysis = None

        # REMOVE raw minimap_2d - LLM should use pre-computed minimap_analysis instead
        # This prevents LLM from doing its own buggy parsing
        # if "minimap_2d" in llm_input_state:
        #     del llm_input_state["minimap_2d"]
        #
        t_context_start = time.time()
        cycle_metrics["context"] = 0
        state_update_start = time.time()

        # Track position for stuck detection
        current_pos = current_mGBA_state.get("position")
        current_map = current_mGBA_state.get("map_id")
        stuck_info = {
            "is_stuck": False,
            "suggestion": "",
        }  # Default if no position data
        if current_pos and current_map:
            position_history.append((current_map, tuple(current_pos)))
            # Keep only last 10 positions
            position_history = position_history[-10:]

            # Check if stuck and record failure
            stuck_info = memory_manager.detect_stuck(position_history)
            if stuck_info["is_stuck"]:
                log.warning(f"🔄 STUCK DETECTED: {stuck_info['suggestion']}")
                llm_input_state["stuck_warning"] = stuck_info["suggestion"]

                # Record failure for replay context
                if last_action and last_position:
                    goal_tracker.record_failure(
                        action=last_action,
                        position=last_position,
                        reason="Position unchanged - movement blocked",
                    )

                # Use the stuck_position from detection (more accurate than current_pos for patterns)
                map_name_for_decay = current_mGBA_state.get("map_name", "")
                stuck_pos = stuck_info.get("stuck_position")
                if stuck_pos and map_name_for_decay:
                    # stuck_pos is a tuple like (map_id, (x, y)), extract coordinates
                    if isinstance(stuck_pos, tuple) and len(stuck_pos) >= 2:
                        coords_to_decay = (
                            list(stuck_pos[1])
                            if isinstance(stuck_pos[1], tuple)
                            else list(stuck_pos)
                        )
                    else:
                        coords_to_decay = list(current_pos) if current_pos else None

                    if coords_to_decay:
                        # NEW: record_failed_exit_attempt now returns dict with status
                        failure_result = memory_manager.record_failed_exit_attempt(
                            map_name_for_decay, coords_to_decay
                        )

                        # Append the suggestion to stuck_warning
                        if failure_result.get("suggestion"):
                            llm_input_state["stuck_warning"] += (
                                f" {failure_result['suggestion']}"
                            )

                        # If there are untried directions, emphasize them
                        untried = failure_result.get("untried_directions", [])
                        if (
                            untried
                            and failure_result.get("status")
                            == "try_different_direction"
                        ):
                            llm_input_state["stuck_warning"] += (
                                f" 🧭 TRY APPROACHING FROM: {untried[0]}"
                            )

        # Add memory context to LLM input
        map_name = current_mGBA_state.get("map_name", "")
        memory_context = memory_manager.get_context_for_llm(map_name)
        if memory_context:
            llm_input_state["memory_context"] = memory_context
            log.info(f"📝 Memory context: {memory_context[:100]}...")

        # Add NPC avoidance context (only if map_name is available)
        if map_name:
            npc_context = memory_manager.get_npc_interaction_context(map_name)
            if npc_context:
                llm_input_state["npc_warning"] = npc_context
                log.info(f"🚫 NPC context: {npc_context}")

        # Add learned strategies context
        # Build situation string from current state for relevance matching
        situation_keywords = []
        party = current_mGBA_state.get("party", [])
        if party:
            total_hp = sum(p.get("hp", 0) for p in party if isinstance(p, dict))
            max_hp = sum(p.get("max_hp", 1) for p in party if isinstance(p, dict))
            if max_hp > 0 and total_hp / max_hp < 0.3:
                situation_keywords.append("low HP")
            if all(p.get("hp", 0) == 0 for p in party if isinstance(p, dict)):
                situation_keywords.append("fainted")
        if stuck_info.get("is_stuck"):
            situation_keywords.append("stuck")
            situation_keywords.append("lost")
        situation_str = " ".join(situation_keywords) if situation_keywords else map_name

        strategy_context = memory_manager.get_strategy_context_for_llm(situation_str)
        if strategy_context:
            llm_input_state["strategy_hints"] = strategy_context
            log.info(f"💡 Strategy context: {strategy_context[:80]}...")

        # Track exploration and add context
        map_id = current_mGBA_state.get("map_id", 0)
        pos = current_mGBA_state.get("position", [0, 0])
        minimap_2d = current_mGBA_state.get("minimap_2d", "")

        if pos and len(pos) >= 2:
            # Record this tile as visited
            # NEW: Calculate total walkable tiles in the local minimap/window to allow % calculation
            total_walkable = 0
            if minimap_2d:
                # Count walkable chars found in typical minimap string
                # is_walkable(tile) usually includes: W, P, O, D, E, >, <, ^, v
                # We simply count chars that are in this set
                for char in minimap_2d:
                    if char in ["W", "P", "O", "D", "E", ">", "<", "^", "v"]:
                        total_walkable += 1

            exploration_tracker.record_visit(
                map_id, map_name, pos[0], pos[1], total_walkable=total_walkable
            )

            # Add exploration context to LLM input
            exploration_context = exploration_tracker.get_context_for_llm(
                map_id, map_name, pos[0], pos[1], minimap_2d
            )
            if exploration_context:
                llm_input_state["exploration_context"] = exploration_context
                log.info(f"🗺️ Exploration: {exploration_context.split(chr(10))[0]}")

            # Track coordinates for loop detection and navigation progress
            coord_tracker.add_position(current_cycle, pos[0], pos[1], map_name)

            # Check for loops and add warning to LLM
            loop_warning = coord_tracker.detect_loop()
            if loop_warning:
                llm_input_state["loop_warning"] = loop_warning
                log.warning(f"⚠️ {loop_warning}")

            # Add target progress context
            target_progress = coord_tracker.get_progress_toward_target()
            if target_progress.get("has_target"):
                llm_input_state["navigation_target"] = target_progress
                log.info(
                    f"🎯 Target: {target_progress.get('progress', 'unknown')} - {target_progress.get('recommendation', '')[:60]}"
                )

            # Check if target reached
            if coord_tracker.check_target_reached():
                log.info("✅ Navigation target reached!")

            # Add full coordinate history context to help LLM understand movement patterns
            coord_context = coord_tracker.get_context_summary()
            if coord_context:
                llm_input_state["coordinate_history"] = coord_context
                log.info(
                    f"📍 Coord context: {len(coord_tracker.history)} positions tracked"
                )

            # ═══════════════════════════════════════════════════════════════════════════
            # NAVIGATION CONTROLLER UPDATE - Goal-based navigation with computed paths
            # ═══════════════════════════════════════════════════════════════════════════
            # Pause context timing
            cycle_metrics["context"] += (time.time() - t_context_start) * 1000

            t_nav_start = time.time()
            try:
                nav_state = navigation_controller.update(
                    current_cycle=current_cycle,
                    current_pos=(pos[0], pos[1]),
                    current_map_name=map_name,
                    current_map_id=map_id,
                    position_history=position_history,
                )

                # Add navigation context to LLM input
                nav_context = navigation_controller.get_context_for_llm(map_name)
                if nav_context:
                    llm_input_state["navigation_goal"] = nav_context
                    log.info(
                        f"🧭 Nav: {nav_state.goal_description[:60]} (status: {nav_state.stuck_status})"
                    )

                # Add computed path suggestion if available
                if nav_state.next_moves:
                    llm_input_state["computed_path"] = (
                        f"FOLLOW THIS PATH: {nav_state.next_moves}"
                    )
                    log.info(f"🛤️ Path: {nav_state.next_moves}")

                # Add navigation suggestion
                if nav_state.suggestion:
                    llm_input_state["navigation_suggestion"] = nav_state.suggestion

            except Exception as e:
                log.warning(f"Navigation controller update failed: {e}")

            cycle_metrics["nav"] = (time.time() - t_nav_start) * 1000
            # Resume context timing
            t_context_start = time.time()

            # ═══════════════════════════════════════════════════════════════════════════
            # DEV MARKERS CONTEXT - Special locations like starter Pokémon
            # ═══════════════════════════════════════════════════════════════════════════
            try:
                from core.dev_markers import get_dev_marker_registry

                dev_marker_registry = get_dev_marker_registry()

                # Add dev marker context if on a map with special locations
                dev_marker_context = dev_marker_registry.get_llm_context_for_map(
                    map_name=map_name, current_world_x=pos[0], current_world_y=pos[1]
                )

                if dev_marker_context:
                    llm_input_state["special_locations"] = dev_marker_context

                    # Special handling for starter Pokémon choice
                    starters = dev_marker_registry.get_starter_pokemon_choices(map_name)
                    party_size = len(current_mGBA_state.get("pokemon", []))

                    if starters and len(starters) > 0 and party_size == 0:
                        # We're in Oak's Lab with 0 Pokemon - starter selection time!
                        log.info(
                            f"STARTER POKEMON AVAILABLE: {len(starters)} choices, party size: {party_size}"
                        )

                        # Check if we've already made a choice
                        if not starter_planner.has_plan:
                            # Generate starter choice via LLM
                            log.info("Generating starter choice via LLM...")
                            try:
                                from core.name_generator import (
                                    generate_starter_choice_sync,
                                )

                                choice = generate_starter_choice_sync(
                                    client, MODEL, temperature=0.9
                                )
                                starter_planner.set_choice(
                                    choice.species, choice.nickname
                                )
                                log.info(
                                    f"Starter choice made: {choice.species} nicknamed '{choice.nickname}'"
                                )

                                # Set navigation goal to the starter
                                scenario_manager.set_starter_navigation_goal(
                                    {
                                        "map_name": map_name,
                                        "map_id": map_id,
                                        "world_x": pos[0],
                                        "world_y": pos[1],
                                    },
                                    cycle_count,
                                )
                            except Exception as e:
                                log.error(f"Failed to generate starter choice: {e}")
                                # Fallback - let LLM choose manually
                                starter_names = [s.label for s in starters]
                                llm_input_state["starter_choice_available"] = (
                                    f"CHOOSE YOUR STARTER POKEMON! Available: {', '.join(starter_names)}\n"
                                    f"Navigate to the Pokeball and press A."
                                )

                        # Add context about the preplanned choice for AI commentary
                        if starter_planner.has_plan:
                            nav_context = starter_planner.get_nav_context(
                                pos[0], pos[1]
                            )
                            llm_input_state["starter_choice_context"] = (
                                f"STARTER POKEMON SELECTION (PREPLANNED)\n"
                                f"You chose: {starter_planner.species} nicknamed '{starter_planner.nickname}'!\n"
                                f"{nav_context}\n"
                                f"Express your excitement about this choice in your commentary!"
                            )
                    elif starters and len(starters) > 0 and party_size > 0:
                        # Already have a starter - mark as obtained if not already
                        if (
                            starter_planner.has_plan
                            and not starter_planner._starter_obtained
                        ):
                            starter_planner.mark_starter_obtained()
                            log.info(f"Starter {starter_planner.species} obtained!")

            except Exception as e:
                log.warning(f"Dev markers context failed: {e}")

        # ═══════════════════════════════════════════════════════════════════════════
        # MAP OSCILLATION DETECTION - Prevent bouncing between same maps
        # ═══════════════════════════════════════════════════════════════════════════
        current_map_name = current_mGBA_state.get("map_name", "")
        map_changed_this_cycle = False  # Track for enhanced target prompt
        if current_map_name and current_map_name != last_map_name:
            # Map changed! Record the transition
            map_changed_this_cycle = True
            entry_pos = current_mGBA_state.get("position")
            map_visit_tracker.record_map_entry(
                map_name=current_map_name,
                cycle=cycle_count,
                position=tuple(entry_pos) if entry_pos else None,
                from_map=last_map_name,
                action=last_action,
            )
            last_map_name = current_map_name

            # Prepare state for LLM
        # This includes formatting the inventory, pokemon, etc.
        # It updates the 'state' dictionary in-place

        # --- TWITCH PREDICTIONS LOGIC ---
        # Detect battle start/end
        current_in_battle = state.get("battle_type", "None") != "None"

        if twitch_service.is_available and hasattr(twitch_service, "engagement"):
            # START BATTLE
            if current_in_battle and not was_in_battle:
                opponent = state.get("opponent_name", "Wild Pokemon")
                is_gym = "Gym" in state.get("map_name", "") or "Leader" in opponent

                # Only create predictions for significant battles or random chance
                import random

                if is_gym or random.random() < 0.3:
                    # Fire a prediction task without blocking loop
                    asyncio.create_task(
                        twitch_service.engagement.predictions.create_prediction(
                            title=f"Will Lass win against {opponent}?",
                            outcomes=["WIN", "LOSE/RUN"],
                            duration=60,
                        )
                    )

            # END BATTLE
            elif was_in_battle and not current_in_battle:
                # Resolve prediction if one is active
                # We assume "WIN" if we didn't black out?
                # This is tricky without more state.
                # For now, let's just resolve as WIN (index 0) if party is healthy?
                # Or maybe we just close it.
                # Actually, let's assume WIN for now to complete the flow.
                # Ideally we check 'won_battle' flag if it existed.
                # For now, if we are NOT in battle anymore and map didn't change to "Pokemon Center", maybe win?
                asyncio.create_task(
                    twitch_service.engagement.predictions.resolve_prediction(
                        winning_outcome_index=0
                    )
                )

        was_in_battle = current_in_battle
        # -------------------------------

        # Check for oscillation patterns
        oscillation = map_visit_tracker.detect_oscillation()
        if oscillation:
            llm_input_state["map_loop_warning"] = oscillation["warning"]
            log.warning(f"🔄 {oscillation['warning']}")

        # Add map visit frequency context
        visit_frequency = map_visit_tracker.get_visit_frequency_context()
        if visit_frequency:
            llm_input_state["map_visit_frequency"] = visit_frequency

        # Add pre-computed minimap analysis to reduce LLM hallucination
        if minimap_2d:
            # Pass world position for coordinate conversion
            world_pos = current_mGBA_state.get("position", [])
            minimap_analysis = parse_minimap(minimap_2d, world_position=world_pos)
            if minimap_analysis and "error" not in minimap_analysis:
                # Format as VERY CLEAR human-readable string for LLM
                # Make blocked directions VERY obvious
                blocked = minimap_analysis["blocked_directions"]
                walkable = minimap_analysis["walkable_directions"]
                exits = minimap_analysis["all_exit_tiles"]
                adj = minimap_analysis["adjacent_tiles"]
                passages = minimap_analysis.get("passages", [])
                npcs = minimap_analysis.get("npc_tiles", [])

                # Retrieve current exploration percentage for frontend display
                exp_map = exploration_tracker.maps.get(map_id)
                if exp_map:
                    # Set in state for persistence
                    state["explorationPct"] = exp_map.exploration_pct
                    # Add to update payload for frontend
                    update_payload["explorationPct"] = exp_map.exploration_pct
                    log.info(
                        f"🌍 Exploration: {exp_map.exploration_pct:.1f}% ({len(exp_map.visited_tiles)}/{exp_map.total_walkable})"
                    )

                # Get target context for LLM (includes both meta-goal and tile target)
                player_grid_x = minimap_analysis.get("player_col", 10)
                player_grid_y = minimap_analysis.get("player_row", 10)
                target_context = target_tracker.get_full_navigation_context(
                    player_grid_x, player_grid_y, map_name
                )

                analysis_str = (
                    f"⚠️ MINIMAP DATA (Viewport centered on you - usually 21x21, smaller at map edges!) ⚠️\n"
                    f"Grid: {minimap_analysis['grid_size']} | Player: {minimap_analysis['player_position']}\n"
                    f"═══════════════════════════════════════\n"
                    f"NORTH: {adj['north']} {'❌ BLOCKED!' if 'NORTH (U)' in blocked else '✓ walkable'}\n"
                    f"SOUTH: {adj['south']} {'❌ BLOCKED!' if 'SOUTH (D)' in blocked else '✓ walkable'}\n"
                    f"EAST:  {adj['east']} {'❌ BLOCKED!' if 'EAST (R)' in blocked else '✓ walkable'}\n"
                    f"WEST:  {adj['west']} {'❌ BLOCKED!' if 'WEST (L)' in blocked else '✓ walkable'}\n"
                    f"═══════════════════════════════════════\n"
                    f"🚫 BLOCKED MOVES: {blocked if blocked else 'NONE - all directions open'}\n"
                    f"✅ ALLOWED MOVES: {walkable}\n"
                    f"🚪 EXIT TILES: {exits if exits else 'None visible in current view'}\n"
                    f"🚶 NPCs: {npcs if npcs else 'None visible'}\n"
                    f"🌉 PASSAGES (paths through walls - might lead somewhere!): {chr(10).join(passages) if passages else 'None detected'}\n"
                    f"{target_context}"
                )
                llm_input_state["minimap_data"] = (
                    "See 'minimap_2d' field for ASCII grid layout."
                )
                log.info(
                    f"🗺️ Minimap: Player at {minimap_analysis['player_position']}, "
                    f"blocked: {blocked}, npcs: {len(npcs)}, passages: {len(passages)}"
                )

                # Store grid dimensions for lassMarkings overlay positioning
                # Parse "21x19" format into separate width/height
                try:
                    grid_dims = minimap_analysis["grid_size"].split("x")
                    state["minimapGridSize"] = {
                        "width": int(grid_dims[0]),
                        "height": int(grid_dims[1]),
                    }
                    update_payload["minimapGridSize"] = state["minimapGridSize"]
                except (ValueError, IndexError):
                    pass  # Keep existing or default

                # === INVISIBLE OBSTACLE DETECTION ===
                # If stuck but minimap shows walkable directions, there might be an invisible NPC/object
                # BUT: Only suggest A-press if NOT at a map boundary (black edge)
                if stuck_info.get("is_stuck") and walkable:
                    # Get the direction(s) we've been trying to move
                    # Check if any walkable direction is actually blocked (invisible obstacle)
                    stuck_warning_lower = llm_input_state.get(
                        "stuck_warning", ""
                    ).lower()

                    # Check if player is facing a map boundary (black area)
                    # If so, don't suggest A-press - it's just the edge of the map
                    facing = current_mGBA_state.get("facing", "down")
                    is_at_map_boundary = False

                    # Check if the blocked direction matches a map edge
                    # Map boundaries are indicated by 'X' tiles in the minimap
                    if minimap_analysis and "blocked" in minimap_analysis:
                        blocked_directions = minimap_analysis.get("blocked", [])
                        facing_to_dir = {
                            "up": "N",
                            "down": "S",
                            "left": "W",
                            "right": "E",
                        }
                        if facing_to_dir.get(facing) in blocked_directions:
                            is_at_map_boundary = True
                            log.info(
                                f"📍 At map boundary facing {facing} - NOT an invisible obstacle"
                            )

                    # Suggest A-press to interact with potential NPC/object ONLY if not at map boundary
                    if (
                        "position unchanged" in stuck_warning_lower
                        or "movement blocked" in stuck_warning_lower
                    ) and not is_at_map_boundary:
                        log.warning(
                            f"👻 INVISIBLE OBSTACLE: Minimap shows walkable tiles but movement blocked. Try pressing A to interact!"
                        )
                        llm_input_state["invisible_obstacle_hint"] = (
                            "👻 INVISIBLE OBSTACLE DETECTED: The minimap shows walkable tiles ahead, "
                            "but you can't move. There may be an NPC or object blocking you that isn't shown. "
                            "TRY PRESSING 'A' to interact with the invisible obstacle. If dialogue appears, "
                            "press B to close it and try moving again."
                        )

                        # Mark this location as potential NPC for Lass overlay
                        # The actual confirmation happens when vision detects dialogue
                        current_pos = current_mGBA_state.get("position", [])
                        if current_pos and len(current_pos) >= 2:
                            # Mark the tile in front based on facing direction
                            dx, dy = 0, 0
                            if facing == "up":
                                dy = -1
                            elif facing == "down":
                                dy = 1
                            elif facing == "left":
                                dx = -1
                            elif facing == "right":
                                dx = 1

                            potential_npc_coords = [
                                current_pos[0] + dx,
                                current_pos[1] + dy,
                            ]
                            # Store as potential NPC (will be confirmed if dialogue detected)
                            memory_manager.add_lass_marking(
                                map_name, potential_npc_coords, "N", confidence=0.5
                            )

                # ═══════════════════════════════════════════════════════════════════════════
                # SCENARIO MANAGER - AUTO-GOALS & OVERRIDES (e.g. Pallet Town Loop)
                # ═══════════════════════════════════════════════════════════════════════════
                scenario_manager.check_for_override(llm_input_state, current_cycle)

                # ═══════════════════════════════════════════════════════════════════════════
                # AUTO-SET NAVIGATION GOAL FROM DETECTED EXITS
                # ═══════════════════════════════════════════════════════════════════════════
                # If no navigation goal is active and we can see exits, pick the nearest one
                if exits and not navigation_controller.goal_stack:
                    try:
                        # Parse exit coordinates from format like "Grid[5,10] = World[12,8] (SOUTH EXIT)"
                        # Note: re module is imported at module level
                        nearest_exit = None
                        nearest_dist = float("inf")
                        current_pos = current_mGBA_state.get("position", [])

                        for exit_str in exits:
                            # Extract World coordinates from "World[x,y]" format
                            world_match = re.search(
                                r"World\[(\d+),\s*(\d+)\]", exit_str
                            )
                            if world_match:
                                exit_x, exit_y = (
                                    int(world_match.group(1)),
                                    int(world_match.group(2)),
                                )
                                if current_pos and len(current_pos) >= 2:
                                    dist = abs(exit_x - current_pos[0]) + abs(
                                        exit_y - current_pos[1]
                                    )
                                    if dist < nearest_dist:
                                        nearest_dist = dist
                                        # Extract direction hint (NORTH/SOUTH/EAST/WEST EXIT)
                                        dir_match = re.search(
                                            r"\((\w+)\s+EXIT\)", exit_str
                                        )
                                        direction = (
                                            dir_match.group(1)
                                            if dir_match
                                            else "unknown"
                                        )
                                        nearest_exit = (exit_x, exit_y, direction)

                        if (
                            nearest_exit and nearest_dist <= 15
                        ):  # Only set goal if exit is reasonably close
                            navigation_controller.set_exit_goal(
                                exit_coords=(nearest_exit[0], nearest_exit[1]),
                                map_name=map_name,
                                map_id=map_id,
                                destination=f"{nearest_exit[2]} exit",
                                current_cycle=current_cycle,
                            )
                            log.info(
                                f"🎯 Auto-set navigation goal to nearest exit: {nearest_exit}"
                            )
                    except Exception as e:
                        log.debug(f"Exit goal auto-set failed: {e}")

        # Add battle context using game memory (more accurate than vision)
        try:
            inventory = current_mGBA_state.get("inventory", [])
            battle_context = get_battle_context(sock_ref["socket"], inventory=inventory)
            if battle_context:
                llm_input_state["battle_context"] = battle_context
                log.info(f"⚔️ Battle detected: {battle_context[:80]}...")
        except Exception as e:
            log.debug(f"Battle context error (not in battle): {e}")

        new_team = current_mGBA_state.get("party")
        prev_team = state.get("currentTeam", []) or []
        prev_team_size = len(prev_team) if prev_team else 0
        new_team_size = len(new_team) if new_team else 0

        # 🎉 MILESTONE DETECTION: First Pokemon obtained
        if prev_team_size == 0 and new_team_size > 0:
            log.info("🎉 MILESTONE DETECTED: First Pokemon obtained!")

            # Complete the starter goal
            if goal_tracker.complete_goal_by_keyword("first Pokemon"):
                log.info("✅ Goal auto-completed: Get first Pokemon")

            # Record in memory as a milestone event
            pokemon_name = new_team[0].get("name", "Unknown") if new_team else "Unknown"
            memory_manager.add_gameplay_memory(
                location=current_mGBA_state.get("map_name", "unknown"),
                description=f"MILESTONE: Received starter Pokemon {pokemon_name} from Professor Oak",
                event_type="milestone",
                outcome="obtained_first_pokemon",
                pokemon_involved=[pokemon_name],
            )
            log.info(f"📝 Memory recorded: Obtained starter {pokemon_name}")

            # Record outcome for strategy learning
            memory_manager.record_outcome(
                "goal_complete",
                {"goal": "first_pokemon", "pokemon": pokemon_name},
                {"location": current_mGBA_state.get("map_name", "unknown")},
            )

        # 🩺 HEALTH MONITORING: Detect blackouts and heals for strategy learning
        prev_party_hp = getattr(memory_manager, "_last_party_hp", None)
        if new_team:
            current_hp_total = sum(
                p.get("hp", 0) for p in new_team if isinstance(p, dict)
            )
            current_hp_max = sum(
                p.get("max_hp", 1) for p in new_team if isinstance(p, dict)
            )
            all_fainted = all(
                p.get("hp", 0) == 0 for p in new_team if isinstance(p, dict)
            )

            # Detect blackout (all Pokemon fainted, then respawned with full health)
            if prev_party_hp is not None:
                was_all_fainted = prev_party_hp.get("all_fainted", False)
                prev_hp_total = prev_party_hp.get("total", 0)

                # Blackout detected: was fainted, now have HP
                if was_all_fainted and current_hp_total > 0:
                    memory_manager.record_outcome(
                        "blacked_out",
                        {
                            "respawn_location": current_mGBA_state.get(
                                "map_name", "unknown"
                            ),
                            "hp_restored": current_hp_total,
                        },
                        {"was_lost": stuck_info.get("is_stuck", False)},
                    )
                    log.info("💀 Blackout detected - recording as potential strategy")

                # Heal detected: significant HP increase without blackout
                elif current_hp_total > prev_hp_total * 1.5 and not was_all_fainted:
                    memory_manager.record_outcome(
                        "healed",
                        {
                            "location": current_mGBA_state.get("map_name", "unknown"),
                            "hp_before": prev_hp_total,
                            "hp_after": current_hp_total,
                        },
                        {},
                    )

            # Store for next cycle
            memory_manager._last_party_hp = {
                "total": current_hp_total,
                "max": current_hp_max,
                "all_fainted": all_fainted,
            }

        # Build team context for LLM awareness (every cycle)
        team_pokemon_names = []
        if new_team and new_team_size > 0:
            for mon in new_team:
                name = mon.get("name", "Unknown")
                level = mon.get("level", "?")
                team_pokemon_names.append(name)

            # Create detailed team summary for LLM
            team_details = []
            for mon in new_team:
                name = mon.get("name", "Unknown")
                level = mon.get("level", "?")
                hp = mon.get("current_hp", "?")
                max_hp = mon.get("max_hp", "?")
                team_details.append(f"{name} Lv{level} ({hp}/{max_hp}HP)")
            llm_input_state["pokemon_team"] = (
                f"YOUR TEAM ({new_team_size}/6): " + ", ".join(team_details)
            )
            log.info(f"🎮 Team context added: {new_team_size} Pokemon")

        # Update goal context with team awareness
        goal_context = goal_tracker.get_context_for_llm(
            team_size=new_team_size, team_pokemon=team_pokemon_names
        )
        if goal_context:
            llm_input_state["goal_context"] = goal_context
            # Log first line only to avoid spam
            log.info(f"🎯 Goals: {goal_context.split(chr(10))[0]}...")

        # Standard team state update
        if new_team is not None and json.dumps(new_team) != json.dumps(
            state.get("currentTeam")
        ):
            state["currentTeam"] = new_team
            update_payload["currentTeam"] = state["currentTeam"]
            log.info("State Update: currentTeam")

        badge_data = current_mGBA_state.get("badges")
        current_state_badges = state.get("badges")

        # Compare the new list with the stored list
        if badge_data != current_state_badges:
            log.info(
                f"State Update: Badges changed from {current_state_badges} to {badge_data}"
            )
            state["badges"] = badge_data
            update_payload["badges"] = badge_data

        # ═══════════════════════════════════════════════════════════════════════════
        # ACHIEVEMENT DETECTION & TWEET TRIGGERING
        # Check for new achievements and trigger tweet generation in background
        # ═══════════════════════════════════════════════════════════════════════════

        # Check Zora Minor Achievements
        if zora_achievement_tracker:
            try:
                zora_ach = zora_achievement_tracker.check_for_achievements(
                    current_party=new_team or [],
                    current_map=map_name,
                    pokedex_caught=current_mGBA_state.get("pokedex_owned", 0),
                    game_state=current_mGBA_state,
                )
                if zora_ach:
                    log.info(f"🟣 Zora Minor Achievement: {zora_ach.get_title()}")
            except Exception as e:
                log.warning(f"Zora achievement detection error: {e}")

        if tweet_generator.enabled:
            try:
                # Check for new achievements based on current game state
                new_achievement = achievement_tracker.check_for_achievements(
                    current_party=new_team or [],
                    current_badges=badge_data or [],
                    current_map=map_name,
                    game_state=current_mGBA_state,
                )

                if new_achievement:
                    log.info(
                        f"🏆 ACHIEVEMENT UNLOCKED: {new_achievement.achievement_type.value}"
                    )

                    # Also trigger for Zora major achievement
                    if zora_achievement_tracker:
                        zora_achievement_tracker.trigger_from_major_achievement(
                            new_achievement.achievement_type.value,
                            context=new_achievement.context,
                        )

                    # Trigger tweet generation as background task
                    async def trigger_achievement_tweet(achievement):
                        """Background task to generate and post achievement tweet."""
                        try:
                            outcome = await tweet_generator.generate_and_post_achievement_tweet(
                                achievement=achievement,
                                run_state=run_state,
                                game_state=current_mGBA_state,
                                screenshot_path=SAVED_SCREENSHOT_PATH,  # Include game screenshot
                            )

                            if outcome.result == TweetGeneratorResult.POSTED:
                                achievement_tracker.mark_tweet_posted(
                                    achievement.achievement_type,
                                    outcome.tweet_url,
                                )
                                log.info(
                                    f"🐦 Achievement tweet posted: {outcome.tweet_url}"
                                )
                            else:
                                log.info(
                                    f"🐦 Achievement tweet result: {outcome.result.value}"
                                )
                        except Exception as e:
                            log.error(f"Achievement tweet generation failed: {e}")

                    # Start as background task (non-blocking)
                    asyncio.create_task(trigger_achievement_tweet(new_achievement))

            except Exception as e:
                log.warning(f"Achievement detection error: {e}")

        pos = current_mGBA_state.get("position")
        map_id = current_mGBA_state.get("map_id", "N/A")
        # Ensure map_name is never None (handles missing key AND explicit None value)
        map_name = current_mGBA_state.get("map_name") or ""
        loc_str = "Unknown"
        if pos:
            loc_str = (
                f"{map_name} (Map {map_id}) ({pos[0]}, {pos[1]})"
                if map_name
                else f"Map {map_id} ({pos[0]}, {pos[1]})"
            )
        if loc_str != state.get("minimapLocation"):
            state["minimapLocation"] = loc_str
            update_payload["minimapLocation"] = state["minimapLocation"]
            log.info(f"State Update: minimapLocation -> {loc_str}")

        # Always update minimap timestamp to trigger UI refresh (minimap image changes each cycle)
        minimap_ts = int(time.time() * 1000)
        state["minimapTimestamp"] = minimap_ts
        update_payload["minimapTimestamp"] = minimap_ts

        # Broadcast exploration percentage for current map
        if map_id in exploration_tracker.maps:
            exp_pct = exploration_tracker.maps[map_id].exploration_pct
            state["explorationPct"] = round(exp_pct, 1)
            update_payload["explorationPct"] = state["explorationPct"]

        # Broadcast Lass markings for current map (N=NPC, O=Opening)
        lass_marks = memory_manager.get_lass_markings_for_map(map_name)
        if lass_marks:
            lass_marks = list(lass_marks)  # Make mutable copy

            # CRITICAL FIX: Transform absolute world coordinates to relative grid coordinates
            # The frontend displays a cropped minimap, so absolute coords will be off-screen
            # We calculate the offset based on player position (world vs relative grid)
            if minimap_analysis and current_pos:
                player_world_x, player_world_y = current_pos
                # Default to center (10,10) if analysis missing
                player_grid_x = minimap_analysis.get("player_col", 10)
                player_grid_y = minimap_analysis.get("player_row", 10)

                # Offset = World - Grid
                # e.g. World(50) - Grid(10) = Offset(40)
                # Then Marker(52) - Offset(40) = Relative(12)
                offset_x = player_world_x - player_grid_x
                offset_y = player_world_y - player_grid_y

                # Apply offset to all memory markers
                valid_marks = []
                grid_w = state.get("minimapGridSize", {}).get("width", 21)
                grid_h = state.get("minimapGridSize", {}).get("height", 21)

                for mark in lass_marks:
                    rel_x = mark["x"] - offset_x
                    rel_y = mark["y"] - offset_y

                    # Only keep markers that are visible on the current minimap crop
                    if 0 <= rel_x < grid_w and 0 <= rel_y < grid_h:
                        mark["x"] = rel_x
                        mark["y"] = rel_y
                        valid_marks.append(mark)

                lass_marks = valid_marks
        else:
            lass_marks = []

        # === TARGET TRACKING ===
        # Check meta-goal progress first
        if target_tracker.has_meta_goal:
            meta_reached = target_tracker.check_meta_goal_reached(map_name)
            if meta_reached:
                llm_input_state["meta_goal_reached"] = (
                    f"🎯 META-GOAL REACHED! You arrived at {map_name}! Set a new meta-goal for your next destination."
                )
            else:
                # Increment meta-goal cycle counter
                if target_tracker.meta_goal:
                    target_tracker.meta_goal.cycles_active += 1

                    # Log progress periodically
                    if target_tracker.meta_goal.cycles_active % 10 == 0:
                        log.info(
                            f"🎯 Meta-goal progress: {target_tracker.meta_goal.cycles_active} cycles toward {target_tracker.meta_goal.destination_map}"
                        )

        # Check if current tile target is reached
        # Note: check_reached() also clears target when map changes (returns False in that case)
        target_was_active = target_tracker.has_target
        if target_tracker.has_target and current_pos:
            player_world_x, player_world_y = current_pos
            target_reached = target_tracker.check_reached(
                player_world_x, player_world_y, map_id
            )
            if target_reached:
                log.info("🎯 TILE TARGET REACHED! Notifying LLM to set a new target.")
                if target_tracker.has_meta_goal:
                    llm_input_state["target_reached"] = (
                        f"🎯 TILE TARGET REACHED! Set a new tile target toward meta-goal: {target_tracker.meta_goal.destination_map}"
                    )
                else:
                    llm_input_state["target_reached"] = (
                        "🎯 TILE TARGET REACHED! Set a new tile target. Also consider setting a meta-goal for your destination map."
                    )

        # Enhanced prompt for map change (target auto-cleared)
        # Target was active before check_reached(), but now cleared due to map change
        if (
            map_changed_this_cycle
            and target_was_active
            and not target_tracker.has_target
        ):
            log.info(
                "MAP TRANSITION: Target was cleared, providing enhanced prompt with exits"
            )

            # Build enhanced map transition prompt
            prompt_parts = []
            prompt_parts.append("\nCRITICAL: MAP TRANSITION DETECTED")
            prompt_parts.append(
                f"Map changed from {last_map_name or 'Unknown'} to {current_map_name}."
            )
            prompt_parts.append(
                "Your tile target was automatically cleared (targets are map-specific)."
            )

            if target_tracker.has_meta_goal:
                prompt_parts.append(
                    f"Meta-goal remains active: {target_tracker.meta_goal.destination_map}."
                )

            prompt_parts.append("\nIMMEDIATE ACTION REQUIRED:")
            prompt_parts.append(
                "Set a new tile target in this response using: <target_destination>x,y</target_destination>"
            )

            # Add available exits if minimap_analysis exists
            if minimap_analysis:
                adjacent_exits = minimap_analysis.get("adjacent_exits", [])
                all_exits = minimap_analysis.get("all_exit_tiles", [])

                if adjacent_exits:
                    prompt_parts.append(
                        "\nAdjacent exits (recommend setting target to one):"
                    )
                    for exit_str in adjacent_exits:
                        prompt_parts.append(f"  - {exit_str}")

                if all_exits:
                    prompt_parts.append("\nAll visible exits:")
                    # Limit to 5 exits to prevent prompt bloat
                    for exit_tile in all_exits[:5]:
                        prompt_parts.append(f"  - {exit_tile}")
                    if len(all_exits) > 5:
                        prompt_parts.append(
                            f"  ({len(all_exits) - 5} additional exits not shown)"
                        )

            llm_input_state["target_reached"] = "\n".join(prompt_parts)

        # Log warning if no targets set
        if not target_tracker.has_target and not target_tracker.has_meta_goal:
            log.warning(
                "🎯 NO TARGETS SET - LLM should set both meta-goal and tile target for navigation!"
            )
        elif not target_tracker.has_target:
            log.info("🎯 No tile target - LLM should set one toward meta-goal")

        # Update target grid position based on viewport shift
        # NOTE: Target marker should always be added if it exists, even without minimap_analysis
        if target_tracker.has_target:
            if minimap_analysis and current_pos:
                # Update grid position based on viewport (normal case with minimap)
                player_world_x, player_world_y = current_pos
                player_grid_x = minimap_analysis.get("player_col", 10)
                player_grid_y = minimap_analysis.get("player_row", 10)
                target_tracker.update_grid_position(
                    player_world_x, player_world_y, player_grid_x, player_grid_y
                )

            # Add target marker to lassMarkings for overlay display
            # This should work even without minimap_analysis (fallback positioning)
            target_marker = target_tracker.get_marker_for_overlay()
            if target_marker:
                lass_marks.append(target_marker)
                log.debug(
                    f"🎯 Added target marker to overlay at grid ({target_marker['x']}, {target_marker['y']})"
                )

            # Increment cycle counter for tile target
            target_tracker.increment_cycle()

        # === DEV MARKERS (STATIC SPECIAL LOCATIONS) ===
        # Add developer-defined markers (e.g., starter Pokémon in Oak's lab)
        from core.dev_markers import get_dev_marker_registry

        dev_marker_registry = get_dev_marker_registry()

        if minimap_analysis and current_pos:
            player_world_x, player_world_y = current_pos
            player_grid_x = minimap_analysis.get("player_col", 10)
            player_grid_y = minimap_analysis.get("player_row", 10)
            grid_w = state.get("minimapGridSize", {}).get("width", 21)
            grid_h = state.get("minimapGridSize", {}).get("height", 21)

            dev_markers = dev_marker_registry.get_overlay_markers_for_map(
                map_name,
                player_world_x,
                player_world_y,
                player_grid_x,
                player_grid_y,
                grid_w,
                grid_h,
            )

            # Log dev marker detection for debugging
            log.info(
                f"[DEV_MARKERS] Map: {map_name}, Found {len(dev_markers)} dev markers"
            )
            if dev_markers:
                log.debug(f"[DEV_MARKERS] Markers: {dev_markers}")
                lass_marks.extend(dev_markers)

        # Broadcast markings (with target if present)
        if lass_marks:
            state["lassMarkings"] = lass_marks
            update_payload["lassMarkings"] = lass_marks

        # --- DYNAMIC AVATAR STATE UPDATES ---
        # Update Battle State (camelCase for frontend)
        battle_state = current_mGBA_state.get("battle_state")
        if battle_state:
            state["inBattle"] = battle_state.get("in_battle", False)
            state["battleType"] = battle_state.get("battle_type", None)

            update_payload["inBattle"] = state["inBattle"]
            update_payload["battleType"] = state["battleType"]

            if state["inBattle"] and state["battleType"]:
                # Only log change to avoid spam
                if state.get("_last_battle_type") != state["battleType"]:
                    log.info(f"⚔️ Battle State: {state['battleType']}")
                    state["_last_battle_type"] = state["battleType"]

        # Update Text/Dialog State for Speaking Avatar
        text_state = current_mGBA_state.get("text_state")
        if text_state:
            state["textState"] = text_state
            update_payload["textState"] = state["textState"]

        # Update Menu State (simple heuristic for now)
        menu_state = current_mGBA_state.get("menu_state")
        if menu_state:
            # If menu item count > 0, we can assume a menu is active?
            # Or use explicit flag if available. For now using item count > 0 logic as fallback
            # But relying on vision might be safer for "inMenu".
            # However, memory is faster. Let's send inMenu if item_count > 0 AND text is NOT printing (menus often overlay text)
            # Actually, let's just pass raw menu state if needed, or set inMenu
            is_menu = menu_state.get("menu_item_count", 0) > 0
            state["inMenu"] = is_menu
            update_payload["inMenu"] = is_menu

        # Update Movement State for biking/surfing avatar switching
        movement_state = current_mGBA_state.get("movement_state")
        if movement_state:
            state["movementState"] = movement_state
            update_payload["movementState"] = movement_state
            if movement_state.get("movement_mode") != "walking":
                log.info(f"🚴 Movement mode: {movement_state.get('movement_mode')}")

        # Update Name Entry State (when on character naming screen)
        name_entry_state = current_mGBA_state.get("name_entry_state")
        forced_auto_action = None

        # CRITICAL: Skip name entry if dialog text is present (menu state can persist from earlier)
        dialog_text = text_state.get("text", "") if text_state else ""
        has_dialog = bool(dialog_text and len(dialog_text.strip()) > 0)

        if name_entry_state and not has_dialog:
            state["nameEntryState"] = name_entry_state
            update_payload["nameEntryState"] = name_entry_state

            # Get the name planner
            name_planner = get_name_planner()

            # Detect what type of name we're entering
            dialog_text = text_state.get("text", "") if text_state else ""
            readable_text = current_mGBA_state.get("readable_text", "")

            # Prioritize detection from tile map if available (from state.py)
            detected_type = name_entry_state.get("name_type")
            if detected_type and detected_type != "unknown":
                name_type = detected_type
                name_planner.name_type = name_type  # Sync planner
                log.info(f"Name Entry: Trusting state-detected type '{name_type}'")
            else:
                name_type = name_planner.detect_name_type(dialog_text, readable_text)

            # Check if this is PRESET MENU or KEYBOARD
            is_preset_menu = name_entry_state.get("is_preset_menu", False)
            is_keyboard = name_entry_state.get("is_keyboard", False)

            if is_preset_menu:
                # ═══════════════════════════════════════════════════════════════
                # STAGE 1: PRESET MENU - Enter keyboard mode to type custom name
                # ═══════════════════════════════════════════════════════════════

                cursor_option = name_entry_state.get("cursor_option", "NEW NAME")
                cursor_index = name_entry_state.get("cursor_index", 0)

                # Always enter keyboard mode to type custom names
                recommended_action = "A;"
                forced_auto_action = recommended_action

                # Determine which name we're typing
                if name_type == "player":
                    target_name = "LASS"
                    name_explanation = "your name 'LASS'"
                elif name_type == "rival":
                    # Use preplanned rival name
                    target_name = name_planner.rival_name or "BUTT"
                    name_explanation = (
                        f"rival's name '{target_name}'\n"
                        f"   📢 COMMENTARY INSTRUCTION: Explicitly tell the chat you plan to name him '{target_name}'!"
                    )
                elif name_type == "pokemon":
                    # Check for starter nickname
                    if (
                        starter_planner.waiting_for_nickname
                        and starter_planner.nickname
                    ):
                        target_name = starter_planner.nickname
                        name_explanation = (
                            f"your {starter_planner.species}'s nickname '{target_name}'"
                        )
                    else:
                        target_name = "SKIP"
                        name_explanation = "press START to skip nickname"
                else:
                    target_name = "custom name"
                    name_explanation = "a custom name"

                name_entry_context = (
                    f"🎮 NAME SELECTION MENU\n"
                    f"══════════════════════════════════════\n"
                    f"📍 CURSOR: '{cursor_option}' (Row {cursor_index})\n"
                    f"\n"
                    f"⭐ ENTERING KEYBOARD MODE TO TYPE '{target_name}'!\n"
                    f"\n"
                    f"▶️ ACTION: {recommended_action} (AUTO-EXECUTED)\n"
                    f"   This selects 'NEW NAME' and enters keyboard mode.\n"
                    f"\n"
                    f"💡 Typing {name_explanation}\n"
                    f"   The keyboard sequence is auto-executed!\n"
                    f"\n"
                    f"⚠️ TRUST THIS CONTEXT, NOT VISION - Vision is unreliable for name entry!\n"
                )

                log.info(
                    f"📝 Preset menu: cursor on '{cursor_option}', entering keyboard mode to type '{target_name}'"
                )

            elif is_keyboard:
                # ═══════════════════════════════════════════════════════════════
                # STAGE 2: KEYBOARD MODE - Use pre-computed sequences
                # ═══════════════════════════════════════════════════════════════

                # Activate keyboard tracker
                kb_tracker = get_keyboard_tracker()
                kb_tracker.activate()

                # Get tracked position (more reliable than memory reads)
                tracked_state = kb_tracker.get_state_dict()
                row = tracked_state.get("row", 1)  # 1-indexed for display
                col = tracked_state.get("col", 1)  # 1-indexed for display
                selected_char = tracked_state.get("selected_char", "A")

                # Build context based on name type
                # NOTE: Name entry is AUTO-EXECUTED - LLM just needs context for commentary

                if name_type == "player":
                    # Type "LASS" for player
                    target_name = "LASS"
                    if not name_planner.current_name:
                        name_planner.start_typing(target_name)

                    next_step = name_planner.get_current_step()
                    progress = name_planner.get_progress_string()

                    if next_step:
                        # Calculate target position for context (1-indexed for display)
                        target_row = next_step["to_pos"][0] + 1
                        target_col = next_step["to_pos"][1] + 1
                        step_num = name_planner.current_char_index + 1
                        total_steps = len(name_planner.typing_sequence)

                        name_entry_context = (
                            f"⌨️ KEYBOARD MODE - TYPING 'LASS'\n"
                            f"══════════════════════════════════════\n"
                            f"Auto-executing keyboard input for player name.\n"
                            f"\n"
                            f"📝 Progress: {progress} (Step {step_num}/{total_steps})\n"
                            f"\n"
                            f"🎯 Next character: '{next_step['char']}'\n"
                            f"▶️ Action: {next_step['path']} (AUTO-EXECUTED)\n"
                            f"\n"
                            f"📍 Current: '{selected_char}' (Row {row}, Col {col})\n"
                            f"   Target: '{next_step['char']}' (Row {target_row}, Col {target_col})\n"
                        )
                        forced_auto_action = next_step["path"]
                    elif (
                        name_planner.is_done_typing()
                        and not name_planner.confirmation_sent
                    ):
                        # All letters typed, need to confirm with START
                        forced_auto_action = "START;"
                        name_entry_context = (
                            f"✅ TYPING COMPLETE: '{target_name}'\n"
                            f"══════════════════════════════════════\n"
                            f"All letters typed successfully!\n"
                            f"\n"
                            f"▶️ ACTION: START; (AUTO-EXECUTED)\n"
                            f"\n"
                            f"🔴 CRITICAL - MEMORY_WRITE REQUIRED:\n"
                            f"   After confirming, you MUST write in section 12:\n"
                            f'   "Named myself {target_name}"\n'
                            f"\n"
                            f"Example MEMORY_WRITE entry:\n"
                            f'**12. MEMORY_WRITE**: "Named myself {target_name}"\n'
                        )
                        name_planner.set_confirmation_sent(True)
                    elif name_planner.confirmation_sent:
                        # Wait for state transition
                        name_entry_context = (
                            "✅ Name confirmed. Waiting for transition..."
                        )
                        forced_auto_action = None

                elif name_type == "rival":
                    # Use preplanned rival name (set at run start)
                    if not name_planner.rival_name:
                        # Fallback if somehow not set
                        import random

                        fallback = random.choice(RIVAL_NAME_SUGGESTIONS)
                        name_planner.set_preplanned_rival_name(
                            fallback, source="fallback"
                        )
                        log.warning(f"Rival name not set, using fallback: {fallback}")

                    target_name = name_planner.rival_name

                    if (
                        not name_planner.current_name
                        or name_planner.current_name != target_name
                    ):
                        name_planner.start_typing(target_name)

                    next_step = name_planner.get_current_step()
                    progress = name_planner.get_progress_string()

                    if next_step:
                        step_num = name_planner.current_char_index + 1
                        total_steps = len(name_planner.typing_sequence)

                        name_entry_context = (
                            f"⌨️ KEYBOARD MODE - TYPING RIVAL NAME '{target_name}'\n"
                            f"══════════════════════════════════════\n"
                            f"📢 COMMENTARY ACTION: Tell the chat you've decided to name him '{target_name}'!\n"
                            f"   e.g. 'I know exactly what to call him... {target_name}!'\n"
                            f"\n"
                            f"Auto-executing keyboard input...\n"
                            f"\n"
                            f"📝 Progress: {progress} (Step {step_num}/{total_steps})\n"
                            f"\n"
                            f"🎯 Next character: '{next_step['char']}'\n"
                            f"▶️ Action: {next_step['path']} (AUTO-EXECUTED)\n"
                            f"\n"
                            f"📍 Current cursor: '{selected_char}' (Row {row}, Col {col})\n"
                        )
                        forced_auto_action = next_step["path"]
                    elif (
                        name_planner.is_done_typing()
                        and not name_planner.confirmation_sent
                    ):
                        forced_auto_action = "START;"
                        name_entry_context = (
                            f"✅ TYPING COMPLETE: '{target_name}'\n"
                            f"══════════════════════════════════════\n"
                            f"All letters typed successfully!\n"
                            f"\n"
                            f"▶️ ACTION: START; (AUTO-EXECUTED)\n"
                            f"\n"
                            f"🔴 CRITICAL - MEMORY_WRITE REQUIRED:\n"
                            f"   After confirming, you MUST write in section 12:\n"
                            f'   "Named rival {target_name}"\n'
                            f"\n"
                            f"Example MEMORY_WRITE entry:\n"
                            f'**12. MEMORY_WRITE**: "Named rival {target_name}"\n'
                        )
                        name_planner.set_confirmation_sent(True)
                    elif name_planner.confirmation_sent:
                        name_entry_context = (
                            "✅ Name confirmed. Waiting for transition..."
                        )
                        forced_auto_action = None

                elif name_type == "pokemon":
                    # Pokemon nickname entry
                    # Check if this is a starter nickname (preplanned)
                    if (
                        starter_planner.waiting_for_nickname
                        and starter_planner.nickname
                    ):
                        target_name = starter_planner.nickname

                        if (
                            not name_planner.current_name
                            or name_planner.current_name != target_name
                        ):
                            name_planner.set_current_starter_nickname(target_name)
                            name_planner.start_typing(target_name)

                        next_step = name_planner.get_current_step()
                        progress = name_planner.get_progress_string()

                        if next_step:
                            step_num = name_planner.current_char_index + 1
                            total_steps = len(name_planner.typing_sequence)

                            name_entry_context = (
                                f"KEYBOARD MODE - TYPING '{target_name}'\n"
                                f"==================================\n"
                                f"Auto-executing keyboard input for starter nickname.\n"
                                f"\n"
                                f"Progress: {progress} (Step {step_num}/{total_steps})\n"
                                f"\n"
                                f"Next character: '{next_step['char']}'\n"
                                f"Action: {next_step['path']} (AUTO-EXECUTED)\n"
                            )
                            forced_auto_action = next_step["path"]
                        elif (
                            name_planner.is_done_typing()
                            and not name_planner.confirmation_sent
                        ):
                            forced_auto_action = "START;"
                            name_entry_context = (
                                f"TYPING COMPLETE: '{target_name}'\n"
                                f"==================================\n"
                                f"Confirming nickname for your {starter_planner.species}!\n"
                                f"\n"
                                f"ACTION: START; (AUTO-EXECUTED)\n"
                            )
                            name_planner.set_confirmation_sent(True)
                            starter_planner.mark_nickname_entered()
                        elif name_planner.confirmation_sent:
                            name_entry_context = (
                                "Nickname confirmed. Waiting for transition..."
                            )
                            forced_auto_action = None
                    else:
                        # No preplanned nickname - skip
                        name_entry_context = (
                            f"POKEMON NICKNAME (SKIPPING)\n"
                            f"==================================\n"
                            f"Cursor: '{selected_char}' (Row {row}, Col {col})\n"
                            f"\n"
                            f"Skipping nickname to continue faster.\n"
                            f"\n"
                            f"ACTION: START; (Skip nickname)\n"
                        )
                        forced_auto_action = "START;"

                else:
                    # UNKNOWN NAME TYPE - Do NOT auto-execute
                    # This happens during confirmation screens or ambiguous states
                    name_entry_context = (
                        f"❓ UNKNOWN NAME ENTRY STATE\n"
                        f"══════════════════════════════════════\n"
                        f"Could not determine if Player, Rival, or Pokemon.\n"
                        f"Auto-execution DISABLED. Manual control active.\n"
                    )
                    forced_auto_action = None
                    log.info("Name Entry: Unknown type - yielding control to agent.")

            else:
                # Unknown state - shouldn't happen
                name_entry_context = (
                    f"🎮 NAME ENTRY DETECTED\n"
                    f"══════════════════════════════════════\n"
                    f"Processing name entry state...\n"
                )

            llm_input_state["name_entry_context"] = name_entry_context
            log.info(
                f"✅ Added name_entry_context: type={name_type}, preset={is_preset_menu}, keyboard={is_keyboard}"
            )
        else:
            # Deactivate keyboard tracker when not in name entry
            kb_tracker = get_keyboard_tracker()
            if kb_tracker.active:
                kb_tracker.deactivate()
                # Reset name planner for next name entry session
                name_planner = get_name_planner()
                name_planner.reset()

            # Debug: Log menu state to trace why name entry wasn't detected
            menu_state = current_mGBA_state.get("menu_state", {})
            if menu_state:
                log.info(
                    f"name_entry_state=None, menu_state: item_count={menu_state.get('menu_item_count')}, "
                    f"cursor=({menu_state.get('cursor_x')},{menu_state.get('cursor_y')}), "
                    f"selected={menu_state.get('selected_item')}"
                )

        # Stop context timing
        cycle_metrics["context"] += (time.time() - t_context_start) * 1000

        # Start Image Prep timing
        t_img_start = time.time()

        # Default: Analysis uses the clean snapshot unless combined
        ANALYSIS_IMAGE_PATH = SCREENSHOT_PATH
        UI_IMAGE_PATH = SCREENSHOT_PATH

        if ONE_IMAGE_PER_PROMPT and MINIMAP_ENABLED:
            try:
                # Load images
                # VALIDATION: Use SCREENSHOT_PATH (the atomic snapshot) instead of global SAVED_SCREENSHOT_PATH
                ss_img = Image.open(SCREENSHOT_PATH)
                mm_img = Image.open(SAVED_MINIMAP_PATH)

                # Resize minimap to match screenshot height
                mm_ratio = ss_img.height / mm_img.height
                new_mm_width = int(mm_img.width * mm_ratio)
                mm_img = mm_img.resize((new_mm_width, ss_img.height), Image.LANCZOS)

                # Create a new canvas wide enough for both
                combined_width = ss_img.width + mm_img.width
                combined = Image.new("RGB", (combined_width, ss_img.height))

                # Paste screenshot at (0,0), minimap at (ss.width, 0)
                combined.paste(ss_img, (0, 0))
                combined.paste(mm_img, (ss_img.width, 0))

                # Save combined image and override SCREENSHOT_PATH
                # VALIDATION: Derive combined path from atomic snapshot path to keep it unique per cycle
                combined_path = (
                    os.path.splitext(SCREENSHOT_PATH)[0] + "_with_minimap.png"
                )
                combined.save(combined_path)

                # CRITICAL: AI gets the combined image, but UI gets the clean original
                ANALYSIS_IMAGE_PATH = combined_path
                # UI_IMAGE_PATH remains SCREENSHOT_PATH (the clean snapshot)

                log.info(f"Combined screenshot + minimap saved to {combined_path}")
            except Exception as e:
                log.error(f"Failed to combine minimap: {e}")
                # Fallback: Analysis path remains SCREENSHOT_PATH

        # Handle image processing based on provider
        if CURRENT_MODE == "ZAI" and vision_manager:
            # For Z.AI MCP, use the CLEAN screenshot (UI_IMAGE_PATH) per user request
            # The minimap context is no longer sent to vision - user prefers clean image
            llm_input_state["screenshot_path"] = UI_IMAGE_PATH
            if not ONE_IMAGE_PER_PROMPT and MINIMAP_ENABLED:
                llm_input_state["minimap_path"] = MINIMAP_PATH

            # Also create base64 versions for fallback
            # CRITICAL: Encode the UI_IMAGE_PATH (clean snapshot) for the frontend
            b64_ss = encode_image_base64(UI_IMAGE_PATH)
            if b64_ss:
                # Note: We send the CLEAN screenshot to the LLM's vision input here as valid base64
                # But the MCP tool will use 'screenshot_path' (ANALYSIS_IMAGE_PATH)
                llm_input_state["screenshot"] = {
                    "image_url": {
                        "url": f"data:image/png;base64,{b64_ss}",
                        "detail": IMAGE_DETAIL,
                    }
                }
                # CRITICAL: Also set top-level screenshot_base64 for vision callback to use
                # This ensures the UI gets the screenshot when vision analysis completes
                llm_input_state["screenshot_base64"] = b64_ss
            else:
                llm_input_state["screenshot"] = None

            # Note: Explicitly attaching the clean screenshot base64 for the UI later
            # This happens in the log creation step using 'b64_ss'

            if not ONE_IMAGE_PER_PROMPT and MINIMAP_ENABLED:
                b64_mm = encode_image_base64(MINIMAP_PATH)
                if b64_mm:
                    llm_input_state["minimap"] = {
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_mm}",
                            "detail": IMAGE_DETAIL,
                        }
                    }
        else:
            # Standard base64 image processing for other providers
            # Use ANALYSIS path for standard models if they can't handle separate tools?
            # Actually, standard models viewing the image directly should probably see the combined one if enabled.
            # But the user specifically asked for "render the cycle not the one with the minimap" on the UI.
            # So we must differentiate what we send to LLM vs what we send to UI.

            # For standard LLM input (e.g. GPT-4o), we want it to see the Minimap if enabled.
            b64_llm = encode_image_base64(ANALYSIS_IMAGE_PATH)

            # But for UI, we want clean.
            b64_ss = encode_image_base64(UI_IMAGE_PATH)

            # Store raw base64 for UI screenshot display (used by vision callback)
            llm_input_state["screenshot_base64"] = b64_ss

            # CRITICAL: Set screenshot_path for Vision Manager (ComfyUI)
            # Use ANALYSIS_IMAGE_PATH (with minimap) if available, otherwise UI_IMAGE_PATH
            # This ensures Vision Manager has a file path to process!
            llm_input_state["screenshot_path"] = (
                ANALYSIS_IMAGE_PATH
                if os.path.exists(ANALYSIS_IMAGE_PATH)
                else UI_IMAGE_PATH
            )

            if b64_llm:
                llm_input_state["screenshot"] = {
                    "image_url": {
                        "url": f"data:image/png;base64,{b64_llm}",
                        "detail": IMAGE_DETAIL,
                    }
                }
            else:
                llm_input_state["screenshot"] = None

            if not ONE_IMAGE_PER_PROMPT and MINIMAP_ENABLED:
                b64_mm = encode_image_base64(MINIMAP_PATH)
                if b64_mm:
                    llm_input_state["minimap"] = {
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_mm}",
                            "detail": IMAGE_DETAIL,
                        }
                    }
            else:
                llm_input_state["minimap"] = None

        cycle_metrics["img_prep"] = (time.time() - t_img_start) * 1000

        log.info(
            f"Pre-LLM state update & image prep took {time.time() - state_update_start:.2f}s. SS:{bool(b64_ss)}, MM:{bool(b64_mm)}"
        )

        # NEW: Get Contextual Area Hint
        area_hint = get_area_hint(llm_input_state)
        if area_hint:
            llm_input_state["area_hint"] = area_hint
            log.info(
                f"💡 AREA HINT: {area_hint.splitlines()[0] if area_hint else 'None'}"
            )

        log_id_counter = state.get("log_id_counter", 0) + 1
        state["log_id_counter"] = log_id_counter

        # Broadcast "ANALYZING..." status AND screenshot before starting vision+LLM processing
        # This allows the UI to show the new screenshot with CRT animation during the vision phase
        state["processingStatus"] = "ANALYZING VISION..."
        await broadcast_func(
            {
                "processingStatus": "ANALYZING VISION...",
                "vision_log": {
                    "id": log_id_counter,
                    "text": "Analyzing screenshot...",
                    "is_vision": True,
                    "timestamp": int(time.time() * 1000),
                    "screenshot_base64": b64_ss,  # Send screenshot immediately so CRT animation plays now
                },
            }
        )

        # LOG ALL KEY STATE FIELDS BEING SENT TO LLM (for debugging)
        key_fields = [
            f"map_name={llm_input_state.get('map_name', 'None')}",
            f"position={llm_input_state.get('position', 'None')}",
            f"dialog_text={llm_input_state.get('dialog_text', 'None')[:30] if llm_input_state.get('dialog_text') else 'None'}...",
            f"in_battle={llm_input_state.get('battle_state', {}).get('in_battle', False)}",
            f"name_entry_context={'YES' if llm_input_state.get('name_entry_context') else 'NO'}",
            f"text_state.is_printing={llm_input_state.get('text_state', {}).get('is_printing', False)}",
            f"menu_state.item_count={llm_input_state.get('menu_state', {}).get('menu_item_count', 0)}",
        ]
        log.info(f"📊 LLM INPUT STATE: {' | '.join(key_fields)}")

        # ═══════════════════════════════════════════════════════════════════════════
        # PARALLEL CHAT + LLM PROCESSING
        # Run chat responses during LLM wait to keep stream active
        # ═══════════════════════════════════════════════════════════════════════════
        # ═══════════════════════════════════════════════════════════════════════════
        # PARALLEL CHAT + LLM PROCESSING
        # Run chat responses during LLM wait to keep stream active
        # ═══════════════════════════════════════════════════════════════════════════
        chat_stop_event = asyncio.Event()
        parallel_chat_task = asyncio.create_task(
            run_chat_background_task(
                chat_stop_event,
                tts_service,
                twitch_service,
                cycle_count,
                pumpfun_service,  # Pump.fun service for crypto chat
                zora_chat_service,  # Zora service for onchain chat
                chat_response_service,  # For Featherless LLM responses
            )
        )

        try:
            # Run the main LLM call via Controller
            (
                action,
                game_analysis,
                summary_json,
                vision_analysis_for_ui,
            ) = await controller.call_with_timeout(
                llm_input_state,
                STREAM_TIMEOUT,
                LLM_TOTAL_TIMEOUT,
                benchmark,
                cycle_metrics,
            )

            # ------------------------------------------------------------------
            # INTRO PACING CONTROL (User Request)
            # ------------------------------------------------------------------
            # Prevent 'A' spam during intro to allow name entry detection to catch up
            # and prevent accidental skipping of the name entry screen.
            # Heuristic: Party 0 AND not in Players House 2F (0x26 = 38).
            if action and not forced_auto_action:
                _map_id = current_mGBA_state.get("map_id", 0)
                _party_size = current_mGBA_state.get("party_size", 0)

                if _party_size == 0 and _map_id != 38:  # 38 is PLAYERS_HOUSE_2F
                    # Check if action is purely 'A' presses (e.g. "A;", "A;A;", "A;A;A;")
                    _commands = [cmd for cmd in action.split(";") if cmd.strip()]
                    if all(cmd == "A" for cmd in _commands) and len(_commands) > 1:
                        log.info(
                            f"Intro Pacing: Throttling '{action}' to single 'A;' to prevent skipping"
                        )
                        action = "A;"

            # OVERRIDE ACTION IF AUTO-EXECUTION IS PENDING
            if forced_auto_action:
                log.info(f"🤖 Auto-executing name entry action: {forced_auto_action}")
                action = forced_auto_action
            tokens_used_session = controller.tokens_used_session

            # ═══════════════════════════════════════════════════════════════
            # FRESH STATE CHECK & SCENARIO OVERRIDE (Fix Race Conditions)
            # ═══════════════════════════════════════════════════════════════
            fresh_state = get_fast_game_state(sock)
            override_action = scenario_manager.check_for_override(
                fresh_state, cycle_count
            )

            if override_action:
                log.info(
                    f"⚠️ SCENARIO OVERRIDE: {override_action} (based on fresh state)"
                )
                action = override_action
                # Clear analysis to avoid confusion in UI
                game_analysis = "Scenario Override (Auto-Executed)"

        finally:
            # Signal chat task to stop and cancel if still running
            chat_stop_event.set()

            # Cancel current chat TTS (so it doesn't talk over game commentary)
            if tts_service and tts_service.is_available:
                tts_service.cancel_pending_chat_responses()

            t_chat_wait_start = time.time()
            try:
                await parallel_chat_task
            except asyncio.CancelledError:
                pass
            except Exception as e:
                log.error(f"Error awaiting parallel chat task: {e}")
            cycle_metrics["chat_wait"] = (time.time() - t_chat_wait_start) * 1000

        # Clear processing status after completion
        state["processingStatus"] = ""
        await broadcast_func({"processingStatus": ""})

        if summary_json is not None:
            tmp = {
                "log_entry": {
                    "id": log_id_counter,
                    "text": "🔎 Chat history cleaned up.",
                }
            }
            await broadcast_func(tmp)

            required = ("primaryGoal", "secondaryGoal", "tertiaryGoal", "otherNotes")

            if isinstance(summary_json, dict):
                # summary_json is dict, safe to check for keys
                missing = [k for k in required if k not in summary_json]
                if not missing:
                    state["goals"] = {
                        "primary": summary_json["primaryGoal"],
                        "secondary": summary_json["secondaryGoal"],
                        "tertiary": summary_json["tertiaryGoal"],
                    }
                    state["otherGoals"] = summary_json["otherNotes"]
                    update_payload["goals"] = state["goals"]
                    update_payload["otherGoals"] = state["otherGoals"]
                else:
                    logging.error(
                        f"Missing required goal keys in summary_json: {missing!r}"
                    )
            else:
                logging.error(
                    f"Expected summary_json to be dict, but got {type(summary_json).__name__!r}"
                )

        if vision_analysis_for_ui:
            update_payload["vision_analysis"] = vision_analysis_for_ui

            # Extract screen_type from vision analysis for UI and chat context
            detected_screen_type = ""
            try:
                vision_json = json.loads(vision_analysis_for_ui)
                detected_screen_type = vision_json.get("screen_type", "")
            except json.JSONDecodeError:
                # Fallback: regex extraction
                match = re.search(
                    r'"screen_type"\s*:\s*"([^"]+)"', vision_analysis_for_ui
                )
                if match:
                    detected_screen_type = match.group(1)

            if detected_screen_type:
                update_payload["screen_type"] = detected_screen_type
                state["screen_type"] = detected_screen_type
                log.debug(f"📺 Detected screen type: {detected_screen_type}")

        action_to_send = None
        log_action_text = "No action taken (LLM failed)."

        # ═══════════════════════════════════════════════════════════════════════════
        # EARLY LOG CREATION & BROADCAST (before action execution for sync)
        # This ensures UI shows reasoning/action BEFORE mGBA executes it
        # ═══════════════════════════════════════════════════════════════════════════

        # Calculate button counts EARLY (needed for action log)
        buttons_in_action = 0
        action_start = action_count
        action_end = action_count
        if action:
            action_buttons = [
                c for c in action.replace(";", "").replace(" ", "") if c in "UDLRABST"
            ]
            buttons_in_action = len(action_buttons)
            action_start = action_count + 1
            action_end = action_count + buttons_in_action
            log.info(
                f"📊 Calculated action counts: #{action_start}-#{action_end} ({buttons_in_action} buttons)"
            )

        # ═══════════════════════════════════════════════════════════════════════════
        # PARSE TARGET DESTINATION from LLM response
        # Format: <target_destination>[x,y] reason: "description"</target_destination>
        # ═══════════════════════════════════════════════════════════════════════════
        if game_analysis:
            # Parse target_destination tag
            target_match = re.search(
                r'<target_destination>\s*\[(\d+)\s*,\s*(\d+)\]\s*(?:reason:\s*["\']?(.+?)["\']?)?\s*</target_destination>',
                game_analysis,
                re.IGNORECASE | re.DOTALL,
            )
            if target_match:
                try:
                    target_grid_x = int(target_match.group(1))
                    target_grid_y = int(target_match.group(2))
                    target_reason = (
                        target_match.group(3).strip()
                        if target_match.group(3)
                        else "Navigation target"
                    )

                    # Convert grid to world coordinates
                    if current_pos and minimap_analysis:
                        player_world_x, player_world_y = current_pos
                        player_grid_x = minimap_analysis.get("player_col", 10)
                        player_grid_y = minimap_analysis.get("player_row", 10)

                        target_world_x, target_world_y = grid_to_world(
                            target_grid_x,
                            target_grid_y,
                            player_grid_x,
                            player_grid_y,
                            player_world_x,
                            player_world_y,
                        )

                        target_tracker.set_target(
                            grid_x=target_grid_x,
                            grid_y=target_grid_y,
                            world_x=target_world_x,
                            world_y=target_world_y,
                            map_id=map_id,
                            map_name=map_name,
                            reason=target_reason,
                        )

                        # Sync to NavigationController to enable BFS pathfinding
                        navigation_controller.set_goal(
                            goal_type=GoalType.COORDINATE,
                            target_x=target_world_x,
                            target_y=target_world_y,
                            map_name=map_name,
                            map_id=map_id,
                            reason=target_reason,
                            current_cycle=current_cycle,
                        )
                except (ValueError, TypeError) as e:
                    log.warning(f"🎯 Failed to parse target_destination: {e}")

            # Also check for <clear_target/>
            if re.search(r"<clear_target\s*/?\s*>", game_analysis, re.IGNORECASE):
                target_tracker.clear_target("llm_cleared")

            # Parse <meta_goal> tag for high-level navigation objectives
            # Format: <meta_goal>MAP_NAME reason: "description"</meta_goal>
            meta_match = re.search(
                r'<meta_goal>\s*(\w+)\s*(?:reason:\s*["\']?(.+?)["\']?)?\s*</meta_goal>',
                game_analysis,
                re.IGNORECASE | re.DOTALL,
            )
            if meta_match:
                try:
                    dest_map = meta_match.group(1).strip().upper()
                    meta_reason = (
                        meta_match.group(2).strip()
                        if meta_match.group(2)
                        else "Navigation goal"
                    )
                    target_tracker.set_meta_goal(dest_map, meta_reason)
                except (ValueError, TypeError) as e:
                    log.warning(f"🎯 Failed to parse meta_goal: {e}")

            # Check for <clear_meta_goal/>
            if re.search(r"<clear_meta_goal\s*/?\s*>", game_analysis, re.IGNORECASE):
                target_tracker.clear_meta_goal("llm_cleared")

        # Create log entries BEFORE action execution
        log_entries = []
        cycle_timestamp = int(time.time() * 1000)

        # Vision log
        if vision_analysis_for_ui:
            vision_log = {
                "id": log_id_counter,
                "text": vision_analysis_for_ui,
                "is_vision": True,
                "timestamp": cycle_timestamp,
                "screenshot_base64": b64_ss,
            }
            log_entries.append(vision_log)
            if b64_ss:
                state["latest_screenshot_base64"] = b64_ss

        # Response log (LLM reasoning)
        if game_analysis and game_analysis.strip():
            response_log = {
                "id": log_id_counter,
                "text": game_analysis.strip(),
                "is_response": True,
                "timestamp": cycle_timestamp,
            }
            log_entries.append(response_log)

        # Action log (intended action - shows BEFORE execution)
        if action and vision_analysis_for_ui:
            action_log = {
                "id": log_id_counter,
                "text": f"Action: {action}",
                "is_action": True,
                "action_start": action_start,
                "action_end": action_end,
                "button_count": buttons_in_action,
                "timestamp": cycle_timestamp,
            }
            log_entries.append(action_log)

        # BROADCAST LOGS IMMEDIATELY (before action execution)
        if "log_history" not in state:
            state["log_history"] = []

        for log_entry in log_entries:
            state["log_history"].insert(0, log_entry)
            if log_entry.get("is_vision"):
                update_payload["vision_log"] = log_entry
                log.info(
                    f"🖼️ Broadcasting vision log: {log_entry.get('text', '')[:50]}..."
                )
            elif log_entry.get("is_response"):
                update_payload["response_log"] = log_entry
                log.info(
                    f"💭 Broadcasting response log: {log_entry.get('text', '')[:50]}..."
                )
            elif log_entry.get("is_action"):
                update_payload["log_entry"] = (
                    log_entry  # For action_payload compatibility
                )
                log.info(
                    f"🎮 Broadcasting action log: {log_entry.get('text', '')[:50]}..."
                )

        state["log_history"] = state["log_history"][:50]

        # Include state counts in the early broadcast (with PREDICTED action count)
        predicted_action_count = action_count + buttons_in_action
        update_payload["cycle"] = current_cycle
        update_payload["actions"] = (
            predicted_action_count  # Show what it WILL be after execution
        )
        update_payload["tokensUsed"] = tokens_used_session
        state["actions"] = predicted_action_count
        state["tokensUsed"] = tokens_used_session

        # BROADCAST NOW - before action execution
        if update_payload:
            await broadcast_func(update_payload)
            log.info(f"📡 Broadcast complete: {list(update_payload.keys())}")

        # ═══════════════════════════════════════════════════════════════════════════
        # TTS COMMENTARY PLAYBACK (BEFORE action execution for proper sync)
        # Order: UI shows reasoning/action → TTS plays → mGBA executes
        # ═══════════════════════════════════════════════════════════════════════════
        if game_analysis and tts_service and tts_service.is_available:
            # Extract commentary from the LLM response
            commentary_match = re.search(
                r"<commentary>([\s\S]*?)</commentary>", game_analysis, re.IGNORECASE
            )

            if not commentary_match:
                # Fallback: various numbered formats
                commentary_match = re.search(
                    r'(?:7|8|9|10|11)\.\s*\*{0,2}COMMENTARY\*{0,2}[:\s]*["\'\(]?(.+?)["\'\)]?(?=\n\d+\.|$|\n\n|</game_analysis>)',
                    game_analysis,
                    re.IGNORECASE | re.DOTALL,
                )

            if commentary_match:
                commentary_text = commentary_match.group(1).strip()
                # Strip <think> tags that sometimes appear in commentary
                commentary_text = re.sub(
                    r"<think>[\s\S]*?</think>",
                    "",
                    commentary_text,
                    flags=re.IGNORECASE | re.DOTALL,
                ).strip()

                # Strip meta_goal and target_destination tags that leak into commentary
                commentary_text = re.sub(
                    r"<meta_goal>[\s\S]*?</meta_goal>",
                    "",
                    commentary_text,
                    flags=re.IGNORECASE | re.DOTALL,
                )
                commentary_text = re.sub(
                    r"<target_destination>[\s\S]*?</target_destination>",
                    "",
                    commentary_text,
                    flags=re.IGNORECASE | re.DOTALL,
                )

                # Handle short tags <t>...</t> and malformed/shorthand tags
                commentary_text = re.sub(
                    r"<t>.*?</t>", "", commentary_text, flags=re.DOTALL | re.IGNORECASE
                )
                # Remove standalone <t>, </t>, </ t>, </t > tags
                commentary_text = re.sub(
                    r"</?t\s*>", "", commentary_text, flags=re.IGNORECASE
                )

                commentary_text = re.sub(r"^[-–•]\s*", "", commentary_text)
                commentary_text = re.sub(
                    r"\n.*$", "", commentary_text
                )  # Take first line only
                commentary_text = commentary_text.strip().strip("\"'")

                if commentary_text and len(commentary_text) > 5:
                    log.info(f"🔊 Playing TTS: {commentary_text[:60]}...")

                    # Update chat response service context with FULL game state
                    # This ensures Lass can give factual answers about her team, location, etc.
                    if chat_response_service.is_available:
                        # Build rich team summary for chat context
                        team_summary = ""
                        if new_team and len(new_team) > 0:
                            team_parts = []
                            for mon in new_team:
                                name = mon.get("name", "Unknown")
                                level = mon.get("level", "?")
                                hp = mon.get("hp", mon.get("current_hp", "?"))
                                max_hp = mon.get("max_hp", "?")
                                team_parts.append(f"{name} Lv{level} ({hp}/{max_hp}HP)")
                            team_summary = ", ".join(team_parts)
                        else:
                            team_summary = "No Pokemon yet!"

                        # Build rich game context
                        game_status = (
                            f"In {current_mGBA_state.get('map_name', 'unknown')}"
                        )
                        if (
                            current_mGBA_state.get("battle_type")
                            and current_mGBA_state.get("battle_type") != "None"
                        ):
                            game_status = f"In battle! ({current_mGBA_state.get('battle_type', 'wild')} battle)"

                        # Include recent actions/history
                        # Note: History tracking could be added later if needed
                        history_text = ""

                        # Fetch $LASS token info for chat context (uses DexScreener, cached 60s)
                        token_info_text = ""
                        t_token_start = time.time()
                        try:
                            token_service = get_token_service()
                            if token_service.is_available:
                                token_info = await token_service.get_token_info()
                                if token_info:
                                    token_info_text = token_info.format_summary()
                        except Exception as ti_err:
                            log.debug(
                                f"Token info fetch failed (non-critical): {ti_err}"
                            )
                        cycle_metrics["token"] = (time.time() - t_token_start) * 1000

                        chat_response_service.update_context(
                            game_context=game_status,
                            commentary=commentary_text,
                            location=current_mGBA_state.get("map_name", "unknown"),
                            team=team_summary,
                            history=history_text,
                            memory=memory_manager.get_narrative_context(),
                            token_info=token_info_text,
                            screen_type=state.get("screen_type", ""),
                        )

                    # Synthesize and play TTS - WAIT for it to complete
                    t_tts_start = time.time()
                    try:
                        await tts_service.synthesize_and_play(
                            commentary_text,
                            priority=tts_service.PRIORITY_COMMENTARY,
                            wait=True,
                        )
                        log.info(f"✅ TTS playback complete")
                    except Exception as tts_err:
                        log.warning(f"🔊 TTS error: {tts_err}")
                    finally:
                        cycle_metrics["tts"] = (time.time() - t_tts_start) * 1000

        # ═══════════════════════════════════════════════════════════════════════════
        # ACTION EXECUTION (now happens AFTER TTS completes)
        # ═══════════════════════════════════════════════════════════════════════════
        t_action_start = time.time()
        if action:
            action_to_send = action
            log_action_text = f"Action: {action}"
            log.info(f"LLM proposed action: {action}")
            try:
                # Broadcast action_execute to trigger button animation in UI
                # This is sent right before mGBA executes so animation syncs with game
                await broadcast_func(
                    {
                        "action_execute": True,
                        "action_buttons": action_to_send.replace(";", " ")
                        .strip()
                        .split(),
                        "buttons_in_action": buttons_in_action,
                    }
                )
                log.info(f"🎮 Broadcasted action_execute for animation trigger")

                # Wait 1s before sending action to let game state settle
                time.sleep(1)
                sock.sendall((action_to_send + "\n").encode("utf-8"))
                log.info(f"Action '{action_to_send}' sent to mGBA.")

                # Update keyboard tracker with the action (for name entry screens)
                kb_tracker = get_keyboard_tracker()
                if kb_tracker.active:
                    # Update tracker with full action (handles D/U/L/R navigation)
                    kb_tracker.apply_action(action_to_send)
                    log.info(
                        f"🎹 Keyboard tracker updated with action: {action_to_send}"
                    )

                    # Advance name planner step if action contains 'A' (typing a character)
                    name_planner = get_name_planner()
                    if "A" in action_to_send.upper() and name_planner.current_name:
                        # Only advance if we're actively typing (not just pressing A on menu)
                        if not name_planner.is_done_typing():
                            name_planner.advance()
                            log.info(
                                f"🎹 Name planner advanced: {name_planner.get_progress_string()}"
                            )
                        else:
                            # Done typing, pressing START will reset the planner
                            log.info(
                                f"🎹 Name planner complete, ready for START confirmation"
                            )

                # Wait 6s AFTER sending action to let screen fully render before next screenshot
                # This prevents cut-off/partial screenshots and ensures dialog text is captured
                time.sleep(6)
                log.info(
                    "Post-action delay complete (6s), ready for next cycle screenshot."
                )

                # Track action execution time (includes 1s pre-wait + send + 6s post-wait)
                cycle_metrics["action"] = (time.time() - t_action_start) * 1000

                # Track for failure replay
                last_action = action_to_send
                last_position = current_pos

                # Track NPC interactions when A button is pressed
                if "A" in action_to_send.upper() and current_pos and map_name:
                    npc_warning = memory_manager.record_npc_interaction(
                        map_name, list(current_pos), npc_name="NPC"
                    )
                    if npc_warning:
                        # Add warning to stuck_warning for next cycle
                        llm_input_state["stuck_warning"] = (
                            llm_input_state.get("stuck_warning", "") + f" {npc_warning}"
                        )

                # VALIDATION: Check if name was just confirmed and MEMORY_WRITE was forgotten
                name_planner = get_name_planner()
                if "S" in action_to_send.upper() or "START" in action_to_send.upper():
                    # Check if we just completed a name entry
                    if name_planner.name_type and game_analysis:
                        expected_memory = None
                        if (
                            name_planner.name_type == "player"
                            and name_planner.player_name
                        ):
                            expected_memory = f"Named myself {name_planner.player_name}"
                        elif (
                            name_planner.name_type == "rival"
                            and name_planner.rival_name
                        ):
                            expected_memory = f"Named rival {name_planner.rival_name}"

                        if expected_memory:
                            # Check if MEMORY_WRITE section contains the expected entry
                            if "MEMORY_WRITE" in game_analysis:
                                # Found MEMORY_WRITE section, check if name is mentioned
                                if expected_memory not in game_analysis:
                                    # MEMORY_WRITE exists but name not mentioned - WARNING!
                                    warning_msg = (
                                        f"⚠️ CRITICAL WARNING: You just confirmed your "
                                        f"{'name' if name_planner.name_type == 'player' else 'rival name'} "
                                        f"but didn't write it to MEMORY_WRITE! "
                                        f"You MUST include: '{expected_memory}' in section 12 or you'll forget the name!"
                                    )
                                    llm_input_state["stuck_warning"] = (
                                        llm_input_state.get("stuck_warning", "")
                                        + f" {warning_msg}"
                                    )
                                    log.warning(f"🔴 {warning_msg}")
                            else:
                                # No MEMORY_WRITE section at all - CRITICAL ERROR!
                                warning_msg = (
                                    f"🔴 CRITICAL ERROR: You just confirmed your "
                                    f"{'name' if name_planner.name_type == 'player' else 'rival name'} "
                                    f"but completely forgot section 12 (MEMORY_WRITE)! "
                                    f"You MUST write: '{expected_memory}' in section 12 immediately or you'll forget the name forever!"
                                )
                                llm_input_state["stuck_warning"] = (
                                    llm_input_state.get("stuck_warning", "")
                                    + f" {warning_msg}"
                                )
                                log.error(f"🔴 {warning_msg}")

            except socket.error as se:
                log.error(
                    f"Socket error sending action '{action_to_send}': {se}. Stopping loop."
                )
                break
            except Exception as e:
                log.error(
                    f"Unexpected error sending action '{action_to_send}': {e}",
                    exc_info=True,
                )

        else:
            log.error("No valid action from LLM. Cannot send command.")
            # User request: Stay on same cycle number if failure occurs
            cycle_count -= 1
            log.info(
                "Decremented cycle count to retry same cycle number on next attempt."
            )

        # Update the GLOBAL action_count variable after execution
        # (state was already updated in the early broadcast with predicted value)
        if action:
            action_count += buttons_in_action
            log.info(
                f"📊 Actions #{action_start}-#{action_end} ({buttons_in_action} buttons) - EXECUTED"
            )

        # Update elapsed time for status display
        elapsed = datetime.datetime.now() - start_time
        elapsed_seconds = elapsed.total_seconds()
        game_status_str = f"{int(elapsed_seconds // 3600)}h {int((elapsed_seconds % 3600) // 60)}m {int(elapsed_seconds % 60)}s"
        state["gameStatus"] = game_status_str
        state["modelName"] = MODEL

        # Force memory recording for important location transitions
        # IMPORTANT: This runs ALWAYS, not just when analysis_text is empty
        try:
            # MAP TRANSITION LOGIC - use current_mGBA_state which has correct keys
            current_map = current_mGBA_state.get("map_name", "unknown")
            current_map_id = current_mGBA_state.get("map_id", -1)
            current_pos = current_mGBA_state.get("position", [])

            # We need to track the PREVIOUS map/pos to record a link
            # use attributes on memory_manager to persist across loops efficiently
            last_map = getattr(memory_manager, "last_map", None)
            last_map_id = getattr(memory_manager, "last_map_id", None)
            last_pos = getattr(memory_manager, "last_pos", None)

            # Check if map changed (and neither is unknown)
            if (
                current_map != "unknown"
                and last_map != "unknown"
                and current_map_id != -1
                and last_map_id != -1
                and (current_map != last_map or current_map_id != last_map_id)
            ):
                # We moved between maps! exact position we left FROM is last_pos
                # exact position we arrived AT is current_pos

                if last_pos and current_pos:
                    # Check if we were on an O tile before transition
                    # This helps determine if this was a natural transition or cutscene
                    last_minimap = getattr(memory_manager, "last_minimap_2d", "")
                    was_on_o_tile = False
                    minimap_had_exit = False

                    if last_minimap:
                        # Check if there was an 'O' tile near player position in last minimap
                        # 'O' tiles indicate exits/entrances
                        rows = last_minimap.split(";")
                        for row in rows:
                            if "O" in row:
                                minimap_had_exit = True
                                break
                        # Check if player marker 'P' was adjacent to or on an 'O'
                        for row_idx, row in enumerate(rows):
                            p_idx = row.find("P")
                            if p_idx >= 0:
                                # Check adjacent tiles for 'O'
                                if (p_idx > 0 and row[p_idx - 1] == "O") or (
                                    p_idx < len(row) - 1 and row[p_idx + 1] == "O"
                                ):
                                    was_on_o_tile = True
                                if (
                                    row_idx > 0
                                    and len(rows[row_idx - 1]) > p_idx
                                    and rows[row_idx - 1][p_idx] == "O"
                                ):
                                    was_on_o_tile = True
                                if (
                                    row_idx < len(rows) - 1
                                    and len(rows[row_idx + 1]) > p_idx
                                    and rows[row_idx + 1][p_idx] == "O"
                                ):
                                    was_on_o_tile = True
                                break

                    new_links = memory_manager.record_transition(
                        from_map=last_map,
                        from_pos=list(last_pos)
                        if isinstance(last_pos, tuple)
                        else last_pos,
                        to_map=current_map,
                        to_pos=list(current_pos)
                        if isinstance(current_pos, tuple)
                        else current_pos,
                        was_on_o_tile=was_on_o_tile,
                        minimap_had_exit=minimap_had_exit,
                    )
                    if new_links:
                        log.info(
                            f"🔗 TRANSITION RECORDED: {last_map} -> {current_map} (O-tile: {was_on_o_tile})"
                        )
                        transition_memory_payload = {
                            "memory_write": {
                                "text": f"Mapped connection: {last_map} -> {current_map}"
                            }
                        }
                        await broadcast_func(transition_memory_payload)

                        # Reset failed attempts and boost confidence since this exit WORKED
                        memory_manager.reset_failed_attempts(
                            last_map,
                            list(last_pos) if isinstance(last_pos, tuple) else last_pos,
                        )

                        # Mark this as a Lass-discovered exit for the minimap overlay
                        memory_manager.mark_lass_exit(
                            last_map,
                            list(last_pos) if isinstance(last_pos, tuple) else last_pos,
                            destination=current_map,
                            confidence=0.95 if was_on_o_tile else 0.8,
                        )

            # Update history for next loop
            memory_manager.last_map = current_map
            memory_manager.last_map_id = current_map_id
            memory_manager.last_pos = current_pos
            memory_manager.last_minimap_2d = current_mGBA_state.get("minimap_2d", "")

            # Extract memories from LLM response and vision analysis
            # CRITICAL FIX: Use current_mGBA_state which has map_name and position keys
            # Also guard against None analysis_text (occurs when vision fails)
            extracted_memories = []
            if game_analysis:
                extracted_memories = memory_manager.extract_memories_from_response(
                    analysis_text=game_analysis,
                    game_state=current_mGBA_state,  # Fixed: was 'state' which has different keys
                    vision_analysis=vision_analysis_for_ui,
                )

            if extracted_memories:
                log.info(
                    f"📝 Extracted {len(extracted_memories)} memories from LLM response"
                )
                for memory in extracted_memories:
                    log.debug(f"Memory: {memory.type} - {memory.description[:50]}...")

                    # ═══════════════════════════════════════════════════════════════════
                    # QUEST GOAL CREATION - When we detect quest items, create goals
                    # Quest goals are CRITICAL priority - they override healing
                    # ═══════════════════════════════════════════════════════════════════
                    if hasattr(memory, "quest_id") and memory.quest_id:
                        # Check if we already have this quest goal
                        if not goal_tracker.has_quest_goal(memory.quest_id):
                            goal_id = goal_tracker.add_quest_goal(
                                quest_id=memory.quest_id,
                                description=memory.description,
                                target_location=getattr(
                                    memory, "target_location", None
                                ),
                                target_npc=getattr(memory, "target_npc", None),
                            )
                            if goal_id:
                                log.info(f"🎯 CREATED QUEST GOAL: {memory.description}")
                                log.info(
                                    f"   Quest ID: {memory.quest_id} | Goal ID: {goal_id}"
                                )
                                # Broadcast quest detection to UI immediately
                                quest_memory_payload = {
                                    "memory_write": {
                                        "text": f"🎯 NEW QUEST: {memory.description}"
                                    }
                                }
                                await broadcast_func(quest_memory_payload)

            # Verify pending vision claims against minimap data
            minimap_2d = current_mGBA_state.get("minimap_2d", "")
            if minimap_2d and current_pos:
                unverified_claims = memory_manager.get_unverified_claims(limit=3)
                for claim in unverified_claims:
                    was_correct = memory_manager.verify_vision_claim(
                        claim=claim,
                        minimap_2d=minimap_2d,
                        player_pos=list(current_pos) if current_pos else [],
                    )
                    if was_correct:
                        log.info(f"✅ Vision claim VERIFIED: {claim.description}")
                    else:
                        log.warning(f"❌ Vision claim WRONG: {claim.description}")

                # Log vision accuracy periodically
                vision_stats = memory_manager.get_vision_accuracy()
                if (
                    vision_stats.get("verified", 0) > 0
                    and vision_stats.get("verified", 0) % 5 == 0
                ):
                    log.info(f"👁️ {vision_stats['message']}")

            # Always get latest memory for broadcasting
            latest_memory = memory_manager.get_latest_memory()
            if latest_memory:
                # Add memory directly to update_payload for frontend compatibility
                memory_write_payload = {
                    "memory_write": {"text": latest_memory.description}
                }
                log.info(
                    f"📝 Broadcasting memory_write to UI: {latest_memory.description[:100]}..."
                )

                # BROADCAST IMMEDIATELY - the main update_payload was already broadcast earlier
                # We need a separate broadcast here for memory_write to reach the UI
                await broadcast_func(memory_write_payload)

                # Also add to memory_updates array for potential future use
                if "memory_updates" not in update_payload:
                    update_payload["memory_updates"] = []
                update_payload["memory_updates"].append(memory_write_payload)

        except Exception as e:
            log.error(f"Error extracting memories: {e}", exc_info=True)
            latest_memory = None

        # Persist run state periodically (after memory extraction so latest_memory is available)
        cycles_since_persist += 1
        if persistence and run_state and cycles_since_persist >= PERSIST_INTERVAL:
            run_state.cycle_count = cycle_count
            run_state.action_count = action_count
            run_state.tokens_used = tokens_used_session
            run_state.elapsed_seconds = elapsed_seconds
            run_state.goals = state.get("goals", run_state.goals)
            run_state.other_goals = state.get("otherGoals", run_state.other_goals)
            run_state.chat_history = controller.chat_history[
                -20:
            ]  # Keep last 20 messages
            run_state.recent_actions.append(action if action else "NONE")
            run_state.recent_actions = run_state.recent_actions[-50:]  # Keep last 50
            if latest_memory:
                run_state.latest_memory = latest_memory.description

            # Save preplanned names for persistence across restarts
            run_state.preplanned_names = name_planner.to_dict()
            run_state.starter_choice = starter_planner.to_dict()

            persistence.save_run_state(run_state)
            cycles_since_persist = 0
            log.info(
                f"Persisted run state: cycle={cycle_count}, actions={action_count}, tokens={tokens_used_session}"
            )

        # Log action to database
        if persistence and run_state and action:
            try:
                persistence.log_action(
                    run_id=run_state.run_id,
                    action=action,
                    screenshot_b64=b64_ss[:1000]
                    if b64_ss
                    else None,  # Truncate for storage
                    llm_analysis=game_analysis[:2000] if game_analysis else None,
                    vision_analysis=vision_analysis_for_ui[:2000]
                    if vision_analysis_for_ui
                    else None,
                    position=current_pos,
                    map_name=map_name,
                    metrics=cycle_metrics,
                )
            except Exception as pe:
                log.warning(f"Failed to log action to database: {pe}")

        # CRITICAL: Check for system failure
        if update_payload and update_payload.get("system_halt"):
            log.critical(
                "🛑 SYSTEM HALT DETECTED - Terminating agent operation immediately"
            )
            break  # Exit the main loop immediately

        # Auto-save game state at end of each cycle
        try:
            # AUTOSAVE enabled: save to slot 1 every cycle for safety
            save_game_state(sock_ref["socket"], slot=1)  # Use slot 1 for regular saves
        except Exception as e:
            log.warning(f"⚠️ Save operation failed: {e} - continuing cycle")

        elapsed_loop_time = time.time() - loop_start_time
        # ORIGINAL llmdriver.py used max(10, ...).
        # Changed to 10s minimum wait as requested.
        wait_time = max(
            0, interval - elapsed_loop_time
        )  # No minimum wait - proceed immediately if under interval
        if result and result.get("stats", {}).get("action_count", 0) > 0:
            log.info(f"💾 Cycle {current_cycle} action execution successful")

        t_cycle_end = time.time()
        cycle_duration_s = (
            t_cycle_end - loop_start_time
        )  # Processing time (from successful mGBA response)
        true_cycle_duration_s = (
            t_cycle_end - true_cycle_start
        )  # True wall clock time (includes mGBA retries)
        cycle_metrics["cycle"] = true_cycle_duration_s * 1000  # Store TRUE cycle time

        log.info(f"⏱️ Total Cycle Time: {true_cycle_duration_s:.2f}s")

        # Detailed timing breakdown
        mgba_s = cycle_metrics.get("mGBA", 0) / 1000
        snapshot_s = cycle_metrics.get("snapshot", 0) / 1000
        deepcopy_s = cycle_metrics.get("deepcopy", 0) / 1000
        context_s = cycle_metrics.get("context", 0) / 1000
        nav_s = cycle_metrics.get("nav", 0) / 1000
        img_prep_s = cycle_metrics.get("img_prep", 0) / 1000
        vision_s = cycle_metrics.get("vision", 0) / 1000
        llm_s = cycle_metrics.get("llm", 0) / 1000
        chat_wait_s = cycle_metrics.get("chat_wait", 0) / 1000
        token_s = cycle_metrics.get("token", 0) / 1000
        tts_s = cycle_metrics.get("tts", 0) / 1000 if "tts" in cycle_metrics else 0
        action_s = (
            cycle_metrics.get("action", 0) / 1000 if "action" in cycle_metrics else 0
        )

        # Calculate accounted vs unaccounted time
        accounted_s = (
            mgba_s
            + snapshot_s
            + deepcopy_s
            + context_s
            + nav_s
            + img_prep_s
            + vision_s
            + llm_s
            + chat_wait_s
            + token_s
            + tts_s
            + action_s
        )
        other_s = max(0, true_cycle_duration_s - accounted_s)

        log.info(
            f"⏱️ Breakdown: mGBA={mgba_s:.1f}s | Snap={snapshot_s:.1f}s | DeepCP={deepcopy_s:.1f}s | "
            f"Context={context_s:.1f}s | Nav={nav_s:.1f}s | Img={img_prep_s:.1f}s | "
            f"Vision={vision_s:.1f}s | LLM={llm_s:.1f}s | ChatW={chat_wait_s:.1f}s | "
            f"Token={token_s:.1f}s | TTS={tts_s:.1f}s | Action={action_s:.1f}s | "
            f"Unaccounted={other_s:.1f}s"
        )

        log.info(
            f"Cycle {current_cycle} took {elapsed_loop_time:.2f}s. Waiting {wait_time:.2f}s..."
        )

        # ═══════════════════════════════════════════════════════════════════
        # Broadcast cycle timing to UI
        # ═══════════════════════════════════════════════════════════════════
        cycle_timing_str = f"{elapsed_loop_time:.1f}s | wait {wait_time:.1f}s"

        # We need to update the log_action call (it happened inside llm_stream_action or executed via persistence object?)
        # Actually persistence.log_action is called where? It hasn't been called yet for this cycle in this scope?
        # Aah, log_action is usually called inside the execute loop or we need to pass these metrics to where it IS called.
        # But wait, run_persistence.log_action is called in `run_auto_loop`? No, it's not.
        # Looking at previous code, `persistence.log_action` was NOT called in `run_auto_loop` explicitly in the visible code.
        # It must be called elsewhere or I missed it.
        # Let's check `llm_stream_action` returns `summary_json`?
        # Ah, `persistence` is global or passed?
        # I need to find where `log_action` is called.

        pass  # Placeholder to allow finding the callsite in next step if needed, or simply patching likely location.

        if broadcast_func:
            try:
                # Track cycle time for average calculation - use TRUE time
                cycle_times_history.append(true_cycle_duration_s)
                # Keep only last 20 cycles for average
                if len(cycle_times_history) > 20:
                    cycle_times_history.pop(0)

                # Calculate average
                avg_cycle_time = (
                    sum(cycle_times_history) / len(cycle_times_history)
                    if cycle_times_history
                    else 0
                )

                # Broadcast enhanced cycle metrics: current cycle, previous cycle, average, and detailed breakdown
                cycle_metrics_payload = {
                    "cycleTiming": cycle_timing_str,
                    "currentCycleTime": round(
                        true_cycle_duration_s, 1
                    ),  # TRUE wall clock time
                    "prevCycleTime": round(prev_cycle_time_s, 1),
                    "avgCycleTime": round(avg_cycle_time, 1),
                    "cycleMetrics": {
                        "mGBA": round(
                            cycle_metrics.get("mGBA", 0) / 1000, 1
                        ),  # Convert ms to s
                        "snapshot": round(cycle_metrics.get("snapshot", 0) / 1000, 1),
                        "deepcopy": round(cycle_metrics.get("deepcopy", 0) / 1000, 1),
                        "context": round(cycle_metrics.get("context", 0) / 1000, 1),
                        "nav": round(cycle_metrics.get("nav", 0) / 1000, 1),
                        "img_prep": round(cycle_metrics.get("img_prep", 0) / 1000, 1),
                        "vision": round(cycle_metrics.get("vision", 0) / 1000, 1),
                        "llm": round(cycle_metrics.get("llm", 0) / 1000, 1),
                        "chat_wait": round(cycle_metrics.get("chat_wait", 0) / 1000, 1),
                        "token": round(cycle_metrics.get("token", 0) / 1000, 1),
                        "tts": round(cycle_metrics.get("tts", 0) / 1000, 1),
                        "action": round(cycle_metrics.get("action", 0) / 1000, 1),
                        "total": round(true_cycle_duration_s, 1),
                    },
                }
                await broadcast_func(cycle_metrics_payload)

                # Also update the shared state so new clients get the values
                state["currentCycleTime"] = round(true_cycle_duration_s, 1)
                state["prevCycleTime"] = round(prev_cycle_time_s, 1)
                state["avgCycleTime"] = round(avg_cycle_time, 1)
            except Exception as e:
                log.warning(f"Failed to broadcast cycle timing: {e}")

        # Store current cycle time as previous for next iteration - use TRUE time
        prev_cycle_time_s = true_cycle_duration_s

        # ═══════════════════════════════════════════════════════════════════════════
        # CHAT RESPONSE PROCESSING (Twitch + Pump.fun during wait period)
        # ═══════════════════════════════════════════════════════════════════════════
        # Mark the current time as when game commentary was sent
        if twitch_service and twitch_service.is_available:
            twitch_service.mark_commentary_timestamp()
        if pumpfun_service and pumpfun_service.is_available:
            pumpfun_service.mark_commentary_timestamp()

        # Process chat messages during wait period
        wait_start = time.time()
        chat_response_count = 0
        max_chat_responses = 3  # Limit responses per cycle to avoid overwhelming

        # Flag to detect if new cycle is starting (for interruption)
        cycle_interrupted = False

        while time.time() - wait_start < wait_time and not cycle_interrupted:
            remaining_wait = wait_time - (time.time() - wait_start)

            # Check if Twitch service is available and we haven't hit response limit
            if (
                not twitch_service.is_available
                or chat_response_count >= max_chat_responses
            ):
                await asyncio.sleep(min(remaining_wait, 2.0))
                continue

            # Check if we're in test mode first (handles its own message generation)
            from services.twitch_chat_service import CHAT_TEST_MODE, CHAT_TEST_LLM

            if CHAT_TEST_MODE:
                # Test mode: Generate message immediately, then wait for TTS
                import random

                if chat_response_count >= max_chat_responses:
                    await asyncio.sleep(1.0)
                    continue

                # Check time remaining - only need 1 second to start a response
                remaining = wait_time - (time.time() - wait_start)
                if remaining < 1:
                    continue

                # Generate one random test message FIRST (no pre-delay!)
                # Randomly choose between Twitch and Pump.fun format for test messages
                if random.random() < 0.5:
                    test_msg = twitch_service.generate_single_test_message()
                else:
                    # Use Pump.fun test message if available, otherwise fallback to Twitch
                    if pumpfun_service:
                        test_msg = pumpfun_service.generate_single_test_message()
                    else:
                        test_msg = twitch_service.generate_single_test_message()

                if test_msg:
                    # Decide: spam = skip, else = respond
                    is_spam = any(
                        spam in test_msg["message"].lower()
                        for spam in [
                            "kekw",
                            "lul",
                            "kappa",
                            "!gamble",
                            "first",
                            "asdf",
                            "zzz",
                            "spam",
                        ]
                    )

                    if is_spam or random.random() < 0.25:  # 25% skip rate
                        log.info(
                            f"⏭️ [TEST] Skipping spam from @{test_msg['display_name']}: {test_msg['message'][:40]}..."
                        )
                        await asyncio.sleep(0.5)  # Brief pause before next message
                    else:
                        # Generate response - use LLM if CHAT_TEST_LLM=true, otherwise mock
                        llm_start = time.time()
                        if CHAT_TEST_LLM and chat_response_service.is_available:
                            # Use actual LLM (Featherless) for realistic latency testing
                            try:
                                response_text = (
                                    await chat_response_service.generate_response(
                                        test_msg["display_name"],
                                        test_msg["message"],
                                        is_past=False,
                                    )
                                )
                                llm_time = time.time() - llm_start
                                log.info(
                                    f'💬 [TEST+LLM] @{test_msg["display_name"]}: "{test_msg["message"]}"'
                                )
                                log.info(
                                    f"🎤 [TEST+LLM] Lass responds (LLM took {llm_time:.2f}s): {response_text}"
                                )
                            except Exception as e:
                                log.warning(
                                    f"[TEST+LLM] LLM error, falling back to mock: {e}"
                                )
                                response_text = f"Hehe thanks @{test_msg['display_name']}! You're awesome!"
                        else:
                            # Use mock responses for speed testing
                            mock_responses = [
                                f"Hehe, thanks for watching @{test_msg['display_name']}! You're making this adventure more fun!",
                                f"Omg hi @{test_msg['display_name']}! I'm so happy you're here with me!",
                                f"@{test_msg['display_name']} Aww you're so sweet! Let's catch 'em all together!",
                                f"Thanks @{test_msg['display_name']}! I may get lost sometimes but at least we're lost together!",
                                f"@{test_msg['display_name']} You're the Pikachu to my Ash! Well... once I actually GET a Pikachu...",
                                f"Haha @{test_msg['display_name']}! True, but hey, every Pokemon master started somewhere!",
                                f"Aww @{test_msg['display_name']} that's so nice! You're giving me all the encouragement I need!",
                                f"@{test_msg['display_name']} Good question! I'm working on it, I promise! Maybe...",
                            ]
                            response_text = random.choice(mock_responses)
                            log.info(
                                f'💬 [TEST] @{test_msg["display_name"]}: "{test_msg["message"]}"'
                            )
                            log.info(f"🎤 [TEST] Lass responds: {response_text}")

                        chat_response_count += 1

                        # Queue TTS (non-blocking) - if queue full, response will just show in UI
                        tts_queued = False
                        if tts_service.is_available:
                            try:
                                # Include reply_to metadata for test messages
                                test_reply_metadata = {
                                    "reply_to": {
                                        "username": test_msg["display_name"],
                                        "platform": "Twitch",
                                        "message": test_msg["message"],
                                    }
                                }
                                request = await tts_service.queue_and_start_synthesis(
                                    response_text,
                                    priority=tts_service.PRIORITY_CHAT_RESPONSE,
                                    cycle_id=current_cycle,
                                    metadata=test_reply_metadata,
                                )
                                tts_queued = request is not None
                                if tts_queued:
                                    log.info(f"✅ [TEST] TTS queued (non-blocking)")
                                else:
                                    log.info(
                                        f"📝 [TEST] TTS queue full, response will only show in UI"
                                    )
                            except Exception as tts_err:
                                log.warning(f"🔊 [TEST] TTS error: {tts_err}")

                        # Broadcast to UI (always - even if TTS queue full)
                        chat_response_payload = {
                            "chat_response": {
                                "username": test_msg["display_name"],
                                "message": test_msg["message"],
                                "response": response_text,
                                "is_test": True,
                                "tts_queued": tts_queued,  # UI can show indicator
                                "timestamp": int(time.time() * 1000),
                            }
                        }
                        await broadcast_func(chat_response_payload)

                        # Brief delay before next potential message
                        await asyncio.sleep(random.uniform(0.5, 1.5))

                continue  # Continue the wait loop

            # Real mode below - get actual messages from ALL configured sources
            messages_for_cycle = []

            # Get Twitch messages
            if twitch_service and twitch_service.is_available:
                twitch_msgs = twitch_service.get_messages_for_cycle_or_test()
                messages_for_cycle.extend(twitch_msgs)

            # Get Pump.fun messages
            if pumpfun_service and pumpfun_service.is_available:
                pumpfun_msgs = pumpfun_service.get_messages_for_cycle_or_test()
                messages_for_cycle.extend(pumpfun_msgs)

            # Get Zora messages
            if zora_chat_service and zora_chat_service.is_available:
                zora_msgs = zora_chat_service.get_messages_for_cycle_or_test()
                messages_for_cycle.extend(zora_msgs)

            # ═══════════════════════════════════════════════════════════════════
            # WHALE DETECTION - Query token balances
            # ═══════════════════════════════════════════════════════════════════
            # Solana/Pump.fun Whales
            token_service = get_token_service()
            if token_service.is_available:
                # Get unique pump.fun wallet addresses that need whale check
                pumpfun_wallets = [
                    msg.get("user_address")
                    for msg in messages_for_cycle
                    if msg.get("source") == "pumpfun"
                    and msg.get("user_address")
                    and not msg.get("is_test", False)  # Skip test messages
                ]

                if pumpfun_wallets:
                    # Batch check whale status (uses cache, very efficient)
                    whale_status = await token_service.check_whale_status_batch(
                        pumpfun_wallets
                    )

                    # Update messages with whale status
                    for msg in messages_for_cycle:
                        if msg.get("source") == "pumpfun":
                            wallet = msg.get("user_address")
                            if wallet and wallet in whale_status:
                                msg["is_whale"] = whale_status[wallet]

            # Base/Zora Whales
            base_token_service = get_base_token_service()
            if base_token_service.is_available:
                zora_wallets = [
                    msg.get("user_address")
                    for msg in messages_for_cycle
                    if msg.get("source") == "zora"
                    and msg.get("user_address")
                    and not msg.get("is_test", False)
                ]

                if zora_wallets:
                    base_status = await base_token_service.check_holder_status_batch(
                        zora_wallets
                    )
                    for msg in messages_for_cycle:
                        if msg.get("source") == "zora":
                            wallet = msg.get("user_address")
                            if wallet and wallet in base_status:
                                msg["is_whale"] = base_status[wallet]

            # ═══════════════════════════════════════════════════════════════════
            # PRIORITY SORTING - Higher priority messages get responded to first
            # ═══════════════════════════════════════════════════════════════════
            # Priority hierarchy (highest first):
            # 1. 👑 WHALE holders (100k+ tokens)          - Priority 5
            # 2. 🟣 Zora subscribers/holders              - Priority 4
            # 3. 💎 Twitch subscribers                    - Priority 3
            # 4. 🪙 Regular pump.fun / Zora chatters      - Priority 2
            # 5. 👤 Regular Twitch chatters               - Priority 1

            def get_priority(msg: dict) -> int:
                source = msg.get("source", "twitch")
                is_whale = msg.get("is_whale", False)
                is_subscriber = msg.get("is_subscriber", False)

                if is_whale:
                    return 5  # Whale - highest priority (any chain)
                elif source == "zora" and is_subscriber:
                    return 4  # Zora subscriber
                elif source == "twitch" and is_subscriber:
                    return 3  # Twitch subscriber
                elif source in ("pumpfun", "zora"):
                    return 2  # Regular crypto chatter
                else:
                    return 1  # Regular Twitch chatter

            # Sort by priority (descending), then by timestamp (ascending/oldest first)
            messages_for_cycle.sort(
                key=lambda m: (-get_priority(m), m.get("timestamp", 0))
            )

            if not messages_for_cycle:
                await asyncio.sleep(min(remaining_wait, 2.0))
                continue

            # Real mode: Use chat response service for decisions
            if not chat_response_service.is_available:
                await asyncio.sleep(min(remaining_wait, 2.0))
                continue

            # Decide SKIP/RESPOND for all messages at once
            decisions = await chat_response_service.decide_skip_or_respond(
                messages_for_cycle
            )

            # Process RESPOND messages oldest first
            for decided in decisions:
                # Find original message object and metadata
                msg_data = next(
                    (
                        m
                        for m in messages_for_cycle
                        if m["timestamp"] == decided.timestamp
                    ),
                    None,
                )
                original_msg = msg_data.get("_original") if msg_data else None
                msg_source = msg_data.get("source", "twitch") if msg_data else "twitch"

                if decided.decision == MessageDecision.SKIP:
                    log.info(
                        f"⏭️ Skipping message from @{decided.display_name} ({msg_source})"
                    )
                    # Mark as responded so we don't process again
                    if original_msg:
                        if msg_source == "twitch" and twitch_service:
                            twitch_service.mark_responded(original_msg)
                        elif hasattr(original_msg, "responded"):
                            original_msg.responded = True
                    continue

                # Check if we should stop for new cycle
                if time.time() - wait_start >= wait_time:
                    log.info("🔄 Cycle time up - interrupting chat responses")
                    tts_service.cancel_current()  # Cancel any playing audio
                    cycle_interrupted = True
                    break

                # Generate and send response
                response_text = await chat_response_service.generate_response(
                    decided.display_name, decided.message, is_past=False
                )

                if response_text:
                    # Format response with @ mention if not already present
                    if not response_text.startswith("@"):
                        response_text = f"@{decided.display_name} {response_text}"

                    # Mark original message as responded
                    if original_msg:
                        if msg_source == "twitch" and twitch_service:
                            twitch_service.mark_responded(original_msg)
                        elif hasattr(original_msg, "responded"):
                            original_msg.responded = True

                    chat_response_count += 1

                    # Queue TTS for the response (non-blocking) - if full, text still goes to chat
                    tts_queued = False
                    if tts_service.is_available:
                        # Pass context about what we are replying to
                        reply_metadata = {
                            "reply_to": {
                                "username": decided.display_name,
                                "platform": msg_source.capitalize(),
                                "message": decided.message,
                            }
                        }

                        request = await tts_service.queue_and_start_synthesis(
                            response_text,
                            priority=tts_service.PRIORITY_CHAT_RESPONSE,
                            cycle_id=current_cycle,
                            metadata=reply_metadata,
                        )
                        tts_queued = request is not None

                    # Send response to appropriate platform
                    if msg_source == "twitch" and twitch_service:
                        await twitch_service.send_response(
                            decided.display_name,
                            response_text.replace(f"@{decided.display_name} ", ""),
                        )
                    elif msg_source == "pumpfun" and pumpfun_service:
                        await pumpfun_service.send_message(response_text)
                    elif msg_source == "zora":
                        # Zora is currently read-only (responses shown in OBS overlay/TTS only)
                        pass

                    # Broadcast chat response to OBS widget
                    chat_response_payload = {
                        "chat_response": {
                            "username": decided.display_name,
                            "response": response_text,
                            "source": msg_source,
                            "is_past_message": False,
                            "timestamp": int(time.time() * 1000),
                        }
                    }
                    await broadcast_func(chat_response_payload)
                    log.info(
                        f"✅ Chat response sent ({msg_source}): {response_text[:50]}..."
                    )

            # If we processed all messages, sleep briefly
            await asyncio.sleep(min(remaining_wait, 1.0))

    log.info("Auto loop terminated.")
    if benchmark is not None:
        benchmark.finalize(current_mGBA_state, MODEL)

save_game_state(sock, slot=0)

Save game state. slot=0 uses QUICKSAVE (faster), slot>0 uses regular SAVESTATE.

Source code in core/llmdriver.py
def save_game_state(sock, slot: int = 0) -> bool:
    """Save game state. slot=0 uses QUICKSAVE (faster), slot>0 uses regular SAVESTATE."""
    try:
        if slot == 0:
            # Use QUICKSAVE for faster saves
            response = send_command(sock, "QUICKSAVE")
            if response and "OK" in response:
                log.info(f"💾 Game state quicksaved")
                return True
            else:
                log.warning(f"Failed to quicksave: {response}")
                return False
        else:
            response = send_command(sock, f"SAVESTATE {slot}")
            if response and "OK" in response:
                log.info(f"💾 Game state saved to slot {slot}")
                return True
            else:
                log.warning(f"Failed to save game state to slot {slot}: {response}")
                return False
    except Exception as e:
        log.error(f"Error saving game state: {e}")
        return False

set_current_mode(mode)

Set the current LLM mode from external selection

Source code in core/llmdriver.py
def set_current_mode(mode):
    """Set the current LLM mode from external selection"""
    global CURRENT_MODE
    CURRENT_MODE = mode

    # Setup LLM client with the selected mode
    global client, MODEL, supports_reasoning, vision_manager
    client, MODEL, supports_reasoning = setup_llm_client(CURRENT_MODE)

    # Note: setup_llm_client returns 3 values (client, model, supports_reasoning)
    # We need to detect if llm_supports_vision is available (some versions might return 4)
    # Actually, looking at client_setup.py, it returns 3 values but prints vision support.
    # To pass it properly, we should read it from config via get_config or env var
    # since we can't easily change the return signature everywhere without breaking things.
    # However, client_setup logic sets env var or we can re-read it.

    # Initialize VisionManager if using ZAI mode or if configured explicitly
    # The updated VisionManager handles its own client creation based on VISION_PROVIDER
    global vision_manager
    vision_manager = VisionManager(client, MODEL, enabled=True)

set_status_callback(callback)

Set the callback for processing status updates.

Source code in core/llmdriver.py
def set_status_callback(callback):
    """Set the callback for processing status updates."""
    global _status_callback
    _status_callback = callback

translate_cardinal_to_buttons(action_str)

Translate cardinal directions (N/S/E/W) to game buttons (U/D/R/L).

CRITICAL: 'S' is ambiguous - it's both 'South' AND 'START' button. We only translate S->D when other cardinal letters (N/E/W) are present, indicating the LLM meant directions, not the START button.

Source code in core/llmdriver.py
def translate_cardinal_to_buttons(action_str: str) -> str:
    """
    Translate cardinal directions (N/S/E/W) to game buttons (U/D/R/L).

    CRITICAL: 'S' is ambiguous - it's both 'South' AND 'START' button.
    We only translate S->D when other cardinal letters (N/E/W) are present,
    indicating the LLM meant directions, not the START button.
    """
    if not action_str:
        return action_str

    # Split into potential tokens to avoid replacing inside words (e.g. START -> DTART)
    tokens = action_str.replace(";", " ; ").split()
    translated_tokens = []

    for token in tokens:
        upper_token = token.upper()
        # Direct cardinal translations
        if upper_token in ["NORTH", "N"]:
            translated_tokens.append("U")
        elif upper_token in ["EAST", "E"]:
            translated_tokens.append("R")
        elif upper_token in ["WEST", "W"]:
            translated_tokens.append("L")
        elif upper_token == "SOUTH":  # Explicit SOUTH -> DOWN
            translated_tokens.append("D")
        elif upper_token == "S":
            # Ambiguous 'S'. If context suggests directions, maybe?
            # But user said: "if we detect that it tried to do 'SOUTH' then we deterministicaly translate to DOWN... we didn't want it to do 'S' which is Start by accident"
            # So 'S' should remain 'S' (Start/Select) unless it explicitly wrote SOUTH.
            # Ideally the LLM shouldn't write SOUTH. If it does, we map to D.
            # If it writes 'S', we assume it means 'S' button (Start/Select handling elsewhere or handled by mGBA as S=Start?)
            # Actually, checking `input_map`: S usually maps to Start? Or Select?
            # Let's keep S as S.
            translated_tokens.append("S")
        elif upper_token == "DOWN":
            translated_tokens.append("D")
        elif upper_token == "UP":
            translated_tokens.append("U")
        elif upper_token == "LEFT":
            translated_tokens.append("L")
        elif upper_token == "RIGHT":
            translated_tokens.append("R")
        else:
            translated_tokens.append(token)

    # Rejoin
    result = "".join(translated_tokens)
    # Fix spacing if we added too many spaces around semicolons, though `send_command` handles spaces fine usually.
    # But let's be clean.
    result = result.replace(" ; ", ";")

    if result != action_str:
        log.info(f"🔄 Translated cardinal directions: {action_str} -> {result}")

    return result

update_processing_status(status)

Update the processing status via callback if set.

Source code in core/llmdriver.py
def update_processing_status(status: str):
    """Update the processing status via callback if set."""
    if _status_callback:
        try:
            _status_callback(status)
        except Exception as e:
            log.warning(f"Status callback error: {e}")

LLM Controller

core.llm_controller

Classes

LLMController

Source code in core/llm_controller.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
class LLMController:
    def __init__(
        self,
        client: Any,
        vision_manager: VisionManager,
        memory_manager: MemoryManager,
        config: Dict[str, Any],
    ):
        """
        Initialize LLM Controller with dependencies and configuration.
        config expected keys:
            mode: str (e.g. "ZAI", "OPENAI")
            model: str
            temperature: float
            max_tokens: int
            is_local: bool
            reasoning_enabled: bool
            reasoning_effort: str
            uses_default_temp: bool
            uses_max_completion_tokens: bool
            minimap_enabled: bool
            minimap_2d: bool
            cleanup_window: int
            system_prompt_unsupported: bool
        """
        self.client = client
        self.vision_manager = vision_manager
        self.memory_manager = memory_manager
        self.config = config

        # State
        self.chat_history: List[Dict] = []
        self.response_count = 0
        self.tokens_used_session = 0
        self._status_callback = None
        self._vision_callback = None

    def set_status_callback(self, callback):
        self._status_callback = callback

    def set_vision_callback(self, callback):
        """Set callback to be called when vision analysis completes.

        Callback signature: (vision_text: str, screenshot_b64: str, new_status: str) -> None
        The new_status is bundled with vision_log for atomic UI update.
        """
        self._vision_callback = callback

    def update_processing_status(self, status: str):
        if self._status_callback:
            self._status_callback(status)

    def broadcast_vision_result(
        self, vision_text: str, screenshot_b64: str = None, new_status: str = ""
    ):
        """Broadcast vision analysis result immediately when available.

        Args:
            vision_text: The vision analysis text
            screenshot_b64: Base64 encoded screenshot
            new_status: New processing status to bundle with the broadcast (e.g., 'THINKING...')
        """
        if self._vision_callback:
            self._vision_callback(vision_text, screenshot_b64, new_status)

    async def call_with_timeout(
        self,
        state_data: dict,
        llm_timeout: float = 30.0,
        total_timeout: float = 75.0,
        benchmark: Any = None,
        cycle_metrics: dict = None,
    ) -> Tuple:
        """Run stream_action in a thread with timeout."""
        loop = asyncio.get_running_loop()
        fn = functools.partial(
            self.stream_action, state_data, llm_timeout, benchmark, cycle_metrics
        )

        executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
        try:
            return await asyncio.wait_for(
                loop.run_in_executor(executor, fn), timeout=total_timeout
            )
        except asyncio.TimeoutError:
            log.error(f"llm_stream_action exceeded {total_timeout}s - skipping cycle.")
            executor.shutdown(wait=False)
            return None, None, None, None
        except Exception:
            executor.shutdown(wait=False)
            raise
        finally:
            executor.shutdown(wait=False)

    def compress_chat_history(self, new_assistant_content: str) -> bool:
        """
        Attempts to compress chat history by merging consecutive identical assistant actions.
        Returns True if compressed.
        """
        if not self.chat_history:
            return False

        last_msg = self.chat_history[-1]
        if last_msg["role"] != "assistant":
            return False

        content = last_msg["content"]
        if not isinstance(content, str):
            return False

        match = re.search(r" \(x(\d+)\)$", content)
        current_count = 1
        clean_content = content

        if match:
            current_count = int(match.group(1))
            clean_content = content[: match.start()]

        if clean_content.strip() == new_assistant_content.strip():
            if len(clean_content) < 150:
                new_count = current_count + 1
                last_msg["content"] = f"{clean_content} (x{new_count})"
                return True

        return False

    def summarize_and_reset(
        self, benchmark: Any = None, state_data: dict = None
    ) -> Optional[str]:
        """Condenses history, updates system prompt, resets history."""
        if not self.chat_history:
            return None

        # Build summary prompt
        summary_prompt = get_summary_prompt()

        # We need a temporary history for summarization
        temp_history = copy.deepcopy(self.chat_history)
        # Append summary request
        temp_history.append({"role": "user", "content": summary_prompt})

        # Calculate tokens
        input_tokens = calculate_prompt_tokens(temp_history)

        log.info(
            f"Summary Context: {len(temp_history)} messages ({input_tokens} tokens)"
        )

        summary_text = None
        try:
            # Non-streaming call for summary
            kwargs = {
                "model": self.config["model"],
                "messages": temp_history,
                "temperature": 0.3,  # Low temp for factual summary
                "max_tokens": 1000,
            }

            if self.config.get("uses_max_completion_tokens"):
                kwargs.pop("max_tokens")
                kwargs["max_completion_tokens"] = 1000

            # Handling ZAI/Legacy API diffs could be here, but usually summary uses standard client
            response = self.client.chat.completions.create(**kwargs)
            summary_text = response.choices[0].message.content.strip()

            # Update token usage
            output_tokens = count_tokens(summary_text)
            self.tokens_used_session += input_tokens + output_tokens

            log.info(f"📝 Summary generated: {summary_text[:100]}...")

        except Exception as e:
            log.error(f"Summarization failed: {e}")
            summary_text = f"Failed to generate summary. Last known state: {state_data.get('map_id', 'unknown') if state_data else 'unknown'}"

        # Reset history
        self.chat_history.clear()

        # Rebuild system prompt
        bench_instr = benchmark.instructions if benchmark else ""
        screen_type = state_data.get("detected_screen_type", "") if state_data else ""
        area_hint = state_data.get("area_hint", "") if state_data else ""

        fresh_prompt = build_system_prompt(
            benchmarkInstruction=bench_instr,
            screen_type=screen_type,
            area_hint=area_hint,
        )

        # Insert System Prompt
        self.chat_history.append({"role": "system", "content": fresh_prompt})

        # Insert Summary if available
        if summary_text:
            self.chat_history.append(
                {
                    "role": "system",
                    "content": f"PREVIOUS SESSION SUMMARY:\n{summary_text}",
                }
            )

        # Parse summary_text as JSON and return the dict directly
        # so primaryGoal, secondaryGoal, etc. are accessible
        if summary_text:
            try:
                parsed = json.loads(summary_text)
                log.info(f"📋 Parsed summary JSON with keys: {list(parsed.keys())}")
                return parsed
            except json.JSONDecodeError as e:
                log.error(f"Failed to parse summary JSON: {e}")
                return {"summary": summary_text}  # Fallback
        return None

    def stream_action(
        self, state_data: dict, timeout: float, benchmark: Any, cycle_metrics: dict
    ):
        """
        Determines and executes an action by querying an LLM.
        """
        if cycle_metrics is None:
            cycle_metrics = {}

        summary_json = None
        vision_analysis_for_ui = None
        payload = copy.deepcopy(state_data)

        # Pop large items
        screenshot = payload.pop("screenshot", None)
        minimap = payload.pop("minimap", None)
        screenshot_path = payload.pop("screenshot_path", None)
        payload.pop("minimap_path", None)

        if not self.config.get("minimap_2d", True):
            payload.pop("minimap_2d", None)

        if not isinstance(payload, dict):
            log.error(f"Invalid state_data structure: {type(state_data)}")
            return None, None, None, None

        # Vision Analysis
        vision_analysis = ""

        if self.vision_manager and screenshot_path and os.path.exists(screenshot_path):
            try:
                self.update_processing_status("ANALYZING VISION...")

                # Vision analysis call (asyncio.run required as we are in a thread)
                processed_vision_result, duration_ms = asyncio.run(
                    self.vision_manager.analyze_image(screenshot_path)
                )

                cycle_metrics["vision"] = duration_ms
                log.info(f"⏱️ Vision Analysis: {duration_ms / 1000:.2f}s")

                if processed_vision_result:
                    log.info(
                        f"✅ Vision Analysis Completed: {len(processed_vision_result)} chars"
                    )
                else:
                    processed_vision_result = ""

                vision_analysis = f"Vision Analysis: {processed_vision_result}"
                vision_analysis_for_ui = processed_vision_result
                payload["vision_analysis"] = vision_analysis
                payload["visual_context"] = processed_vision_result

                # Broadcast vision result immediately (before LLM processing)
                # Bundle processingStatus: "THINKING..." with vision_log for atomic UI update
                # This ensures the screenshot is revealed exactly when vision completes
                screenshot_b64 = state_data.get("screenshot_base64")
                self.broadcast_vision_result(
                    vision_analysis_for_ui, screenshot_b64, "THINKING..."
                )

                # Dynamic prompt updates from vision result
                try:
                    vision_json = json.loads(processed_vision_result)
                    detected_screen_type = vision_json.get("screen_type", "")
                    if detected_screen_type:
                        payload["detected_screen_type"] = detected_screen_type
                except json.JSONDecodeError:
                    match = re.search(
                        r'"screen_type"\s*:\s*"([^"]+)"', processed_vision_result
                    )
                    if match:
                        payload["detected_screen_type"] = match.group(1)

            except Exception as e:
                log.error(f"Vision analysis failed: {e}")
                payload["vision_analysis"] = "[Vision analysis failed]"
                # Handle critical failure if needed
                self.vision_manager.handle_vision_failure(str(e))

        elif self.config["mode"] == "ZAI":
            # ZAI mode but no vision client
            log.warning("ZAI mode detected but no vision available")
            payload["vision_analysis"] = "[Vision client not initialized]"

        elif self.config["mode"] == "ZAI_DIRECT":
            # ZAI_DIRECT mode: Images are embedded directly in API call
            # No separate vision analysis needed - the model handles both vision and analysis
            log.info(
                "ZAI_DIRECT mode: Image will be embedded directly in API call (combined vision+text)"
            )
            # Update status to show we're doing combined analysis
            self.update_processing_status("ANALYZING (COMBINED)...")
            # Set placeholder vision analysis so action logging works in llmdriver
            vision_analysis_for_ui = (
                "[Combined vision+text analysis - image embedded in LLM call]"
            )

        # Build Message
        image_parts = []
        text_content = json.dumps(payload)

        # Include vision analysis directly in text for non-vision LLMs
        if not self.config.get("llm_supports_vision", True) and vision_analysis:
            text_content = f"## 👁️ CURRENT SCREEN VISION ANALYSIS\n{vision_analysis}\n\n**CRITICAL: This is what you see on screen right now. Use this information for your analysis!**\n\n{text_content}"

        current_content = [{"type": "text", "text": text_content}]

        # Only attach images if the LLM supports vision
        llm_supports_vision = self.config.get("llm_supports_vision", True)

        if llm_supports_vision:
            if screenshot and isinstance(screenshot.get("image_url"), dict):
                current_content.append(
                    {"type": "image_url", "image_url": screenshot["image_url"]}
                )
            if (
                minimap
                and self.config.get("minimap_enabled", False)
                and isinstance(minimap.get("image_url"), dict)
            ):
                current_content.append(
                    {"type": "image_url", "image_url": minimap["image_url"]}
                )

        current_user_message = {"role": "user", "content": current_content}

        # Dynamic System Prompt Update
        detected_screen_type = payload.get("detected_screen_type", "")
        area_hint = state_data.get("area_hint", "")
        if self.chat_history and self.chat_history[0].get("role") == "system":
            bench_instr = benchmark.instructions if benchmark else ""
            fresh_prompt = build_system_prompt(
                bench_instr, detected_screen_type, area_hint
            )
            self.chat_history[0] = {"role": "system", "content": fresh_prompt}

        messages = self.chat_history + [current_user_message]

        # Token Accounting
        input_tokens = calculate_prompt_tokens(messages)
        log.info(f"LLM Input Tokens: {input_tokens}")

        # API Call
        full_output = ""
        action = None
        analysis_text = None

        try:
            self.update_processing_status("THINKING...")

            kwargs = {
                "model": self.config["model"],
                "messages": messages,
                "temperature": self.config["temperature"],
                "timeout": timeout,
            }

            if self.config.get("uses_max_completion_tokens"):
                kwargs["max_completion_tokens"] = self.config["max_tokens"]
            else:
                kwargs["max_tokens"] = self.config["max_tokens"]

            if self.config.get("uses_default_temp"):
                kwargs["temperature"] = 1.0

            supports_reasoning = self.config.get("reasoning_enabled", False)

            if supports_reasoning:
                log.info("Using reasoning model (non-streaming).")
                kwargs["stream"] = False

                # ZAI Specific Logic
                if self.config["mode"] == "ZAI":
                    full_output = self._call_zai_api(kwargs, cycle_metrics)
                else:
                    kwargs["reasoning_effort"] = self.config.get(
                        "reasoning_effort", "medium"
                    )
                    t_start = time.time()
                    response = self.client.chat.completions.create(**kwargs)
                    cycle_metrics["llm"] = (time.time() - t_start) * 1000
                    full_output = response.choices[0].message.content

            elif self.config["mode"] == "ZAI_DIRECT":
                # ZAI_DIRECT: Use non-streaming for more reliable multimodal responses
                log.info("Using ZAI_DIRECT mode (non-streaming with embedded images).")
                kwargs["stream"] = False
                t_start = time.time()
                response = self.client.chat.completions.create(**kwargs)
                cycle_metrics["llm"] = (time.time() - t_start) * 1000
                full_output = response.choices[0].message.content
                log.info(f"⏱️ ZAI_DIRECT LLM: {cycle_metrics['llm'] / 1000:.2f}s")

            else:
                log.info("Using standard model (streaming).")
                kwargs["stream"] = True
                t_start = time.time()
                response = self.client.chat.completions.create(**kwargs)
                full_output = self._handle_streaming(response, timeout)
                cycle_metrics["llm"] = (time.time() - t_start) * 1000

        except Exception as e:
            log.error(f"LLM Interaction failed: {e}", exc_info=True)
            return None, None, None, None

        if not full_output:
            return None, None, None, None

        # Post-Processing
        output_tokens = count_tokens(full_output)
        self.tokens_used_session += input_tokens + output_tokens

        # History Management
        user_hist_content = [
            {"type": "text", "text": text_content}
        ]  # No images in history

        if not self.compress_chat_history(full_output):
            self.chat_history.append({"role": "user", "content": user_hist_content})
            self.chat_history.append({"role": "assistant", "content": full_output})

        # Cleanup
        self.response_count += 1
        cleanup_window = self.config.get("cleanup_window", 10)
        if self.response_count >= cleanup_window:
            summary_json = self.summarize_and_reset(benchmark, state_data)
            self.response_count = 0
            time.sleep(1)  # Brief pause

        # Extraction
        analysis_text = self._extract_analysis(full_output)
        action, action_vision_analysis = self._extract_action(full_output, state_data)

        if action_vision_analysis:
            vision_analysis_for_ui = action_vision_analysis

        return action, analysis_text, summary_json, vision_analysis_for_ui

    def _call_zai_api(self, kwargs: dict, cycle_metrics: dict) -> str:
        """Handle ZAI specific API call (manual HTTP to support thinking param)."""
        import os

        # Check if thinking mode is enabled via env var (default: enabled)
        thinking_enabled = os.getenv("ZAI_THINKING_MODE", "enabled").lower()
        use_thinking = thinking_enabled in ("enabled", "true", "1", "yes")

        # Construct ZAI kwargs
        zai_kwargs = {
            "model": kwargs.get("model"),
            "messages": kwargs.get("messages"),
            "stream": False,
            "max_tokens": kwargs.get("max_tokens"),
            "temperature": kwargs.get("temperature"),
        }

        # Convert to text-only messages
        text_only_messages = []
        for msg in zai_kwargs["messages"]:
            content = msg.get("content")
            if isinstance(content, list):
                text = ""
                for item in content:
                    if isinstance(item, dict) and item.get("type") == "text":
                        text += item.get("text", "")
                text_only_messages.append({"role": msg.get("role"), "content": text})
            else:
                text_only_messages.append(msg)

        api_data = {
            "model": zai_kwargs["model"],
            "messages": text_only_messages,
            "max_tokens": zai_kwargs.get("max_tokens"),
            "temperature": zai_kwargs.get("temperature"),
        }

        # Add thinking param if enabled
        if use_thinking:
            api_data["thinking"] = {"type": "enabled"}
            log.info("🧠 ZAI thinking mode enabled")

        headers = {
            "Authorization": f"Bearer {self.client.api_key}",
            "Content-Type": "application/json",
        }

        t_start = time.time()
        max_retries = 3
        retry_delay = 0.5

        for attempt in range(max_retries):
            try:
                with httpx.Client(timeout=40.0) as http_client:
                    response = http_client.post(
                        f"{self.client.base_url}chat/completions",
                        json=api_data,
                        headers=headers,
                    )
                cycle_metrics["llm"] = (time.time() - t_start) * 1000

                if response.status_code == 200:
                    return response.json()["choices"][0]["message"]["content"]
                else:
                    raise Exception(f"ZAI API Error: {response.text}")

            except (
                httpx.RemoteProtocolError,
                httpx.ConnectError,
                httpx.ReadError,
            ) as e:
                # Connection was closed by server or network error - retry
                if attempt < max_retries - 1:
                    log.warning(
                        f"ZAI connection error (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {retry_delay}s..."
                    )
                    time.sleep(retry_delay)
                    retry_delay *= 2  # Exponential backoff
                else:
                    log.error(
                        f"ZAI connection failed after {max_retries} attempts: {e}"
                    )
                    raise e
            except Exception as e:
                raise e

    def _handle_streaming(self, response, timeout) -> str:
        """Handle streaming response."""
        full_output = ""
        start_time = time.time()
        print(">>> ", end="", flush=True)
        try:
            for chunk in response:
                if time.time() - start_time > timeout:
                    raise TimeoutError("Stream timeout")
                delta = chunk.choices[0].delta.content
                if delta:
                    print(delta, end="", flush=True)
                    full_output += delta
        except Exception as e:
            log.warning(f"Stream interrupted: {e}")
        print("")
        return full_output.strip()

    def _extract_analysis(self, full_output: str) -> str:
        match = ANALYSIS_RE.search(full_output)
        if match:
            return f"<game_analysis>{match.group(1).strip()}</game_analysis>"
        # Fallback to non-JSON lines
        lines = [
            l
            for l in full_output.splitlines()
            if not l.strip().startswith("{") and not l.strip().endswith("}")
        ]
        return "\n".join(lines) if lines else "No analysis available"

    def _extract_action(
        self, full_output: str, state_data: dict
    ) -> Tuple[Optional[str], Optional[str]]:
        action = None
        vision = None

        # JSON search
        for json_match in re.finditer(r"\{[^{}]*\}", full_output):
            try:
                parsed = json.loads(json_match.group())
                if "action" in parsed:
                    act = parsed["action"]
                    act = self._translate_cardinal(act)
                    if ACTION_RE.match(act):
                        action = act
                if "touch" in parsed:
                    # Touch logic
                    if COORD_RE.match(parsed["touch"]):
                        coords = [int(i) for i in parsed["touch"].split(",")]
                        action = touch_controls_path_find(
                            state_data.get("map_id", 0), state_data["position"], coords
                        )
                if "vision_analysis" in parsed:
                    vision = parsed["vision_analysis"]
                if action:
                    break
            except:
                continue

        # Fallback regex
        if not action:
            for line in full_output.splitlines():
                if "ACTION" in line.upper() and ":" in line:
                    part = line.split(":", 1)[1].strip()
                    part = self._translate_cardinal(part)
                    if ACTION_RE.match(part):
                        action = part.rstrip(";") + ";"
                        break

        return action, vision

    def _translate_cardinal(self, action_str: str) -> str:
        """Translate cardinal directions to buttons."""
        if not action_str:
            return ""

        mapping = {"N": "U", "S": "D", "E": "R", "W": "L"}
        # Only translate S->D if other cardinals present to avoid START ambiguity
        has_cardinal = any(c in action_str for c in "NEW")

        parts = action_str.split(";")
        translated = []
        for p in parts:
            p = p.strip()
            if not p:
                continue
            if p in mapping:
                if p == "S" and not has_cardinal:
                    translated.append(p)  # Keep S as START
                else:
                    translated.append(mapping[p])
            else:
                translated.append(p)

        return ";".join(translated)
Functions
__init__(client, vision_manager, memory_manager, config)

Initialize LLM Controller with dependencies and configuration. config expected keys: mode: str (e.g. "ZAI", "OPENAI") model: str temperature: float max_tokens: int is_local: bool reasoning_enabled: bool reasoning_effort: str uses_default_temp: bool uses_max_completion_tokens: bool minimap_enabled: bool minimap_2d: bool cleanup_window: int system_prompt_unsupported: bool

Source code in core/llm_controller.py
def __init__(
    self,
    client: Any,
    vision_manager: VisionManager,
    memory_manager: MemoryManager,
    config: Dict[str, Any],
):
    """
    Initialize LLM Controller with dependencies and configuration.
    config expected keys:
        mode: str (e.g. "ZAI", "OPENAI")
        model: str
        temperature: float
        max_tokens: int
        is_local: bool
        reasoning_enabled: bool
        reasoning_effort: str
        uses_default_temp: bool
        uses_max_completion_tokens: bool
        minimap_enabled: bool
        minimap_2d: bool
        cleanup_window: int
        system_prompt_unsupported: bool
    """
    self.client = client
    self.vision_manager = vision_manager
    self.memory_manager = memory_manager
    self.config = config

    # State
    self.chat_history: List[Dict] = []
    self.response_count = 0
    self.tokens_used_session = 0
    self._status_callback = None
    self._vision_callback = None
broadcast_vision_result(vision_text, screenshot_b64=None, new_status='')

Broadcast vision analysis result immediately when available.

Parameters:

Name Type Description Default
vision_text str

The vision analysis text

required
screenshot_b64 str

Base64 encoded screenshot

None
new_status str

New processing status to bundle with the broadcast (e.g., 'THINKING...')

''
Source code in core/llm_controller.py
def broadcast_vision_result(
    self, vision_text: str, screenshot_b64: str = None, new_status: str = ""
):
    """Broadcast vision analysis result immediately when available.

    Args:
        vision_text: The vision analysis text
        screenshot_b64: Base64 encoded screenshot
        new_status: New processing status to bundle with the broadcast (e.g., 'THINKING...')
    """
    if self._vision_callback:
        self._vision_callback(vision_text, screenshot_b64, new_status)
call_with_timeout(state_data, llm_timeout=30.0, total_timeout=75.0, benchmark=None, cycle_metrics=None) async

Run stream_action in a thread with timeout.

Source code in core/llm_controller.py
async def call_with_timeout(
    self,
    state_data: dict,
    llm_timeout: float = 30.0,
    total_timeout: float = 75.0,
    benchmark: Any = None,
    cycle_metrics: dict = None,
) -> Tuple:
    """Run stream_action in a thread with timeout."""
    loop = asyncio.get_running_loop()
    fn = functools.partial(
        self.stream_action, state_data, llm_timeout, benchmark, cycle_metrics
    )

    executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
    try:
        return await asyncio.wait_for(
            loop.run_in_executor(executor, fn), timeout=total_timeout
        )
    except asyncio.TimeoutError:
        log.error(f"llm_stream_action exceeded {total_timeout}s - skipping cycle.")
        executor.shutdown(wait=False)
        return None, None, None, None
    except Exception:
        executor.shutdown(wait=False)
        raise
    finally:
        executor.shutdown(wait=False)
compress_chat_history(new_assistant_content)

Attempts to compress chat history by merging consecutive identical assistant actions. Returns True if compressed.

Source code in core/llm_controller.py
def compress_chat_history(self, new_assistant_content: str) -> bool:
    """
    Attempts to compress chat history by merging consecutive identical assistant actions.
    Returns True if compressed.
    """
    if not self.chat_history:
        return False

    last_msg = self.chat_history[-1]
    if last_msg["role"] != "assistant":
        return False

    content = last_msg["content"]
    if not isinstance(content, str):
        return False

    match = re.search(r" \(x(\d+)\)$", content)
    current_count = 1
    clean_content = content

    if match:
        current_count = int(match.group(1))
        clean_content = content[: match.start()]

    if clean_content.strip() == new_assistant_content.strip():
        if len(clean_content) < 150:
            new_count = current_count + 1
            last_msg["content"] = f"{clean_content} (x{new_count})"
            return True

    return False
set_vision_callback(callback)

Set callback to be called when vision analysis completes.

Callback signature: (vision_text: str, screenshot_b64: str, new_status: str) -> None The new_status is bundled with vision_log for atomic UI update.

Source code in core/llm_controller.py
def set_vision_callback(self, callback):
    """Set callback to be called when vision analysis completes.

    Callback signature: (vision_text: str, screenshot_b64: str, new_status: str) -> None
    The new_status is bundled with vision_log for atomic UI update.
    """
    self._vision_callback = callback
stream_action(state_data, timeout, benchmark, cycle_metrics)

Determines and executes an action by querying an LLM.

Source code in core/llm_controller.py
def stream_action(
    self, state_data: dict, timeout: float, benchmark: Any, cycle_metrics: dict
):
    """
    Determines and executes an action by querying an LLM.
    """
    if cycle_metrics is None:
        cycle_metrics = {}

    summary_json = None
    vision_analysis_for_ui = None
    payload = copy.deepcopy(state_data)

    # Pop large items
    screenshot = payload.pop("screenshot", None)
    minimap = payload.pop("minimap", None)
    screenshot_path = payload.pop("screenshot_path", None)
    payload.pop("minimap_path", None)

    if not self.config.get("minimap_2d", True):
        payload.pop("minimap_2d", None)

    if not isinstance(payload, dict):
        log.error(f"Invalid state_data structure: {type(state_data)}")
        return None, None, None, None

    # Vision Analysis
    vision_analysis = ""

    if self.vision_manager and screenshot_path and os.path.exists(screenshot_path):
        try:
            self.update_processing_status("ANALYZING VISION...")

            # Vision analysis call (asyncio.run required as we are in a thread)
            processed_vision_result, duration_ms = asyncio.run(
                self.vision_manager.analyze_image(screenshot_path)
            )

            cycle_metrics["vision"] = duration_ms
            log.info(f"⏱️ Vision Analysis: {duration_ms / 1000:.2f}s")

            if processed_vision_result:
                log.info(
                    f"✅ Vision Analysis Completed: {len(processed_vision_result)} chars"
                )
            else:
                processed_vision_result = ""

            vision_analysis = f"Vision Analysis: {processed_vision_result}"
            vision_analysis_for_ui = processed_vision_result
            payload["vision_analysis"] = vision_analysis
            payload["visual_context"] = processed_vision_result

            # Broadcast vision result immediately (before LLM processing)
            # Bundle processingStatus: "THINKING..." with vision_log for atomic UI update
            # This ensures the screenshot is revealed exactly when vision completes
            screenshot_b64 = state_data.get("screenshot_base64")
            self.broadcast_vision_result(
                vision_analysis_for_ui, screenshot_b64, "THINKING..."
            )

            # Dynamic prompt updates from vision result
            try:
                vision_json = json.loads(processed_vision_result)
                detected_screen_type = vision_json.get("screen_type", "")
                if detected_screen_type:
                    payload["detected_screen_type"] = detected_screen_type
            except json.JSONDecodeError:
                match = re.search(
                    r'"screen_type"\s*:\s*"([^"]+)"', processed_vision_result
                )
                if match:
                    payload["detected_screen_type"] = match.group(1)

        except Exception as e:
            log.error(f"Vision analysis failed: {e}")
            payload["vision_analysis"] = "[Vision analysis failed]"
            # Handle critical failure if needed
            self.vision_manager.handle_vision_failure(str(e))

    elif self.config["mode"] == "ZAI":
        # ZAI mode but no vision client
        log.warning("ZAI mode detected but no vision available")
        payload["vision_analysis"] = "[Vision client not initialized]"

    elif self.config["mode"] == "ZAI_DIRECT":
        # ZAI_DIRECT mode: Images are embedded directly in API call
        # No separate vision analysis needed - the model handles both vision and analysis
        log.info(
            "ZAI_DIRECT mode: Image will be embedded directly in API call (combined vision+text)"
        )
        # Update status to show we're doing combined analysis
        self.update_processing_status("ANALYZING (COMBINED)...")
        # Set placeholder vision analysis so action logging works in llmdriver
        vision_analysis_for_ui = (
            "[Combined vision+text analysis - image embedded in LLM call]"
        )

    # Build Message
    image_parts = []
    text_content = json.dumps(payload)

    # Include vision analysis directly in text for non-vision LLMs
    if not self.config.get("llm_supports_vision", True) and vision_analysis:
        text_content = f"## 👁️ CURRENT SCREEN VISION ANALYSIS\n{vision_analysis}\n\n**CRITICAL: This is what you see on screen right now. Use this information for your analysis!**\n\n{text_content}"

    current_content = [{"type": "text", "text": text_content}]

    # Only attach images if the LLM supports vision
    llm_supports_vision = self.config.get("llm_supports_vision", True)

    if llm_supports_vision:
        if screenshot and isinstance(screenshot.get("image_url"), dict):
            current_content.append(
                {"type": "image_url", "image_url": screenshot["image_url"]}
            )
        if (
            minimap
            and self.config.get("minimap_enabled", False)
            and isinstance(minimap.get("image_url"), dict)
        ):
            current_content.append(
                {"type": "image_url", "image_url": minimap["image_url"]}
            )

    current_user_message = {"role": "user", "content": current_content}

    # Dynamic System Prompt Update
    detected_screen_type = payload.get("detected_screen_type", "")
    area_hint = state_data.get("area_hint", "")
    if self.chat_history and self.chat_history[0].get("role") == "system":
        bench_instr = benchmark.instructions if benchmark else ""
        fresh_prompt = build_system_prompt(
            bench_instr, detected_screen_type, area_hint
        )
        self.chat_history[0] = {"role": "system", "content": fresh_prompt}

    messages = self.chat_history + [current_user_message]

    # Token Accounting
    input_tokens = calculate_prompt_tokens(messages)
    log.info(f"LLM Input Tokens: {input_tokens}")

    # API Call
    full_output = ""
    action = None
    analysis_text = None

    try:
        self.update_processing_status("THINKING...")

        kwargs = {
            "model": self.config["model"],
            "messages": messages,
            "temperature": self.config["temperature"],
            "timeout": timeout,
        }

        if self.config.get("uses_max_completion_tokens"):
            kwargs["max_completion_tokens"] = self.config["max_tokens"]
        else:
            kwargs["max_tokens"] = self.config["max_tokens"]

        if self.config.get("uses_default_temp"):
            kwargs["temperature"] = 1.0

        supports_reasoning = self.config.get("reasoning_enabled", False)

        if supports_reasoning:
            log.info("Using reasoning model (non-streaming).")
            kwargs["stream"] = False

            # ZAI Specific Logic
            if self.config["mode"] == "ZAI":
                full_output = self._call_zai_api(kwargs, cycle_metrics)
            else:
                kwargs["reasoning_effort"] = self.config.get(
                    "reasoning_effort", "medium"
                )
                t_start = time.time()
                response = self.client.chat.completions.create(**kwargs)
                cycle_metrics["llm"] = (time.time() - t_start) * 1000
                full_output = response.choices[0].message.content

        elif self.config["mode"] == "ZAI_DIRECT":
            # ZAI_DIRECT: Use non-streaming for more reliable multimodal responses
            log.info("Using ZAI_DIRECT mode (non-streaming with embedded images).")
            kwargs["stream"] = False
            t_start = time.time()
            response = self.client.chat.completions.create(**kwargs)
            cycle_metrics["llm"] = (time.time() - t_start) * 1000
            full_output = response.choices[0].message.content
            log.info(f"⏱️ ZAI_DIRECT LLM: {cycle_metrics['llm'] / 1000:.2f}s")

        else:
            log.info("Using standard model (streaming).")
            kwargs["stream"] = True
            t_start = time.time()
            response = self.client.chat.completions.create(**kwargs)
            full_output = self._handle_streaming(response, timeout)
            cycle_metrics["llm"] = (time.time() - t_start) * 1000

    except Exception as e:
        log.error(f"LLM Interaction failed: {e}", exc_info=True)
        return None, None, None, None

    if not full_output:
        return None, None, None, None

    # Post-Processing
    output_tokens = count_tokens(full_output)
    self.tokens_used_session += input_tokens + output_tokens

    # History Management
    user_hist_content = [
        {"type": "text", "text": text_content}
    ]  # No images in history

    if not self.compress_chat_history(full_output):
        self.chat_history.append({"role": "user", "content": user_hist_content})
        self.chat_history.append({"role": "assistant", "content": full_output})

    # Cleanup
    self.response_count += 1
    cleanup_window = self.config.get("cleanup_window", 10)
    if self.response_count >= cleanup_window:
        summary_json = self.summarize_and_reset(benchmark, state_data)
        self.response_count = 0
        time.sleep(1)  # Brief pause

    # Extraction
    analysis_text = self._extract_analysis(full_output)
    action, action_vision_analysis = self._extract_action(full_output, state_data)

    if action_vision_analysis:
        vision_analysis_for_ui = action_vision_analysis

    return action, analysis_text, summary_json, vision_analysis_for_ui
summarize_and_reset(benchmark=None, state_data=None)

Condenses history, updates system prompt, resets history.

Source code in core/llm_controller.py
def summarize_and_reset(
    self, benchmark: Any = None, state_data: dict = None
) -> Optional[str]:
    """Condenses history, updates system prompt, resets history."""
    if not self.chat_history:
        return None

    # Build summary prompt
    summary_prompt = get_summary_prompt()

    # We need a temporary history for summarization
    temp_history = copy.deepcopy(self.chat_history)
    # Append summary request
    temp_history.append({"role": "user", "content": summary_prompt})

    # Calculate tokens
    input_tokens = calculate_prompt_tokens(temp_history)

    log.info(
        f"Summary Context: {len(temp_history)} messages ({input_tokens} tokens)"
    )

    summary_text = None
    try:
        # Non-streaming call for summary
        kwargs = {
            "model": self.config["model"],
            "messages": temp_history,
            "temperature": 0.3,  # Low temp for factual summary
            "max_tokens": 1000,
        }

        if self.config.get("uses_max_completion_tokens"):
            kwargs.pop("max_tokens")
            kwargs["max_completion_tokens"] = 1000

        # Handling ZAI/Legacy API diffs could be here, but usually summary uses standard client
        response = self.client.chat.completions.create(**kwargs)
        summary_text = response.choices[0].message.content.strip()

        # Update token usage
        output_tokens = count_tokens(summary_text)
        self.tokens_used_session += input_tokens + output_tokens

        log.info(f"📝 Summary generated: {summary_text[:100]}...")

    except Exception as e:
        log.error(f"Summarization failed: {e}")
        summary_text = f"Failed to generate summary. Last known state: {state_data.get('map_id', 'unknown') if state_data else 'unknown'}"

    # Reset history
    self.chat_history.clear()

    # Rebuild system prompt
    bench_instr = benchmark.instructions if benchmark else ""
    screen_type = state_data.get("detected_screen_type", "") if state_data else ""
    area_hint = state_data.get("area_hint", "") if state_data else ""

    fresh_prompt = build_system_prompt(
        benchmarkInstruction=bench_instr,
        screen_type=screen_type,
        area_hint=area_hint,
    )

    # Insert System Prompt
    self.chat_history.append({"role": "system", "content": fresh_prompt})

    # Insert Summary if available
    if summary_text:
        self.chat_history.append(
            {
                "role": "system",
                "content": f"PREVIOUS SESSION SUMMARY:\n{summary_text}",
            }
        )

    # Parse summary_text as JSON and return the dict directly
    # so primaryGoal, secondaryGoal, etc. are accessible
    if summary_text:
        try:
            parsed = json.loads(summary_text)
            log.info(f"📋 Parsed summary JSON with keys: {list(parsed.keys())}")
            return parsed
        except json.JSONDecodeError as e:
            log.error(f"Failed to parse summary JSON: {e}")
            return {"summary": summary_text}  # Fallback
    return None

Functions

Scenario Manager

core.scenario_manager

Classes

ScenarioManager

Manages hardcoded game scenarios, overrides, and scripted events. Handles: - Name Entry sequence (player name LASS, LLM-preplanned rival name) - Starter Pokemon selection (LLM-preplanned choice + nickname) - Pallet Town loop breaking - Invisible obstacle handling (stuck state) - Scripted photo moments (Loaded from game_data.yaml)

Source code in core/scenario_manager.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
class ScenarioManager:
    """
    Manages hardcoded game scenarios, overrides, and scripted events.
    Handles:
    - Name Entry sequence (player name LASS, LLM-preplanned rival name)
    - Starter Pokemon selection (LLM-preplanned choice + nickname)
    - Pallet Town loop breaking
    - Invisible obstacle handling (stuck state)
    - Scripted photo moments (Loaded from game_data.yaml)
    """

    def __init__(self, navigation_controller: NavigationController):
        self.navigation_controller = navigation_controller
        self._achievement_tracker = None  # Set externally when initialized
        self._photo_callback = None  # Callback when photo moment triggers

        # Track triggered photo moments this session to avoid spam
        self._triggered_moments: Set[str] = set()

    def set_achievement_tracker(self, tracker) -> None:
        """Set the achievement tracker reference for photo moment triggers."""
        self._achievement_tracker = tracker

    def set_photo_callback(self, callback: Callable) -> None:
        """Set callback function to call when a photo moment is triggered."""
        self._photo_callback = callback

    def check_for_override(
        self, state: Dict[str, Any], cycle_count: int
    ) -> Optional[str]:
        """
        Check game state for forced actions that bypass the LLM.
        Returns an action string (e.g. "A;", "START;") or None.
        """
        # 1. Name Entry Override (handles player, rival, and pokemon nicknames)
        name_entry_action = self._check_name_entry(state)
        if name_entry_action:
            return name_entry_action

        # 2. Starter Selection Override (auto-navigate and select preplanned starter)
        starter_action = self._check_starter_selection(state)
        if starter_action:
            return starter_action

        # 3. Scripted Photo Moments
        self._check_photo_moments(state)

        # 4. Pallet Town Loop Breaker (Only if no goal exists)
        self._check_pallet_town_loop(state, cycle_count)

        return None

    def _check_name_entry(self, state: Dict[str, Any]) -> Optional[str]:
        """
        Handle name entry screen logic.

        Supports:
        - Player name: Always "LASS"
        - Rival name: LLM-preplanned or random fallback
        - Pokemon nickname: From StarterPlanner if available
        """
        name_entry_state = state.get("name_entry_state")
        if not name_entry_state:
            return None

        is_preset = name_entry_state.get("is_preset_menu", False)
        is_keyboard = name_entry_state.get("is_keyboard", False)

        if is_preset:
            # For preset menus, we ALWAYS want to select "NEW NAME" (Index 0)
            # to enter the custom name entry screen.
            cursor_index = name_entry_state.get("cursor_index", 0)

            if cursor_index > 0:
                log.info(
                    f"Preset menu detected (index {cursor_index}): Auto-executing 'UP' to reach 'NEW NAME'"
                )
                return "UP;"
            else:
                log.info(
                    "Preset menu detected (index 0): Auto-executing 'A' to enter custom name mode"
                )
                return "A;"

        if is_keyboard:
            planner = get_name_planner()
            starter_planner = get_starter_planner()

            # Prevent loop: If already confirmed, hand back to agent
            if planner.confirmation_sent:
                return None

            # Initialize typing if not already started
            if not planner.current_name:
                # Determine what we're naming based on name_type
                name_type = planner.name_type

                if name_type == "player":
                    # Always type LASS for player
                    planner.start_typing("LASS")
                    log.info("Name Entry: Typing player name 'LASS'")

                elif name_type == "rival":
                    # Use preplanned rival name (LLM-generated or random fallback)
                    rival_name = planner.get_planned_name()
                    if rival_name:
                        planner.start_typing(rival_name)
                        log.info(
                            f"Name Entry: Typing preplanned rival name '{rival_name}'"
                        )
                    else:
                        # Fallback to random if somehow not set
                        fallback = planner.get_random_rival_name()
                        planner.set_preplanned_rival_name(fallback, source="fallback")
                        planner.start_typing(fallback)
                        log.info(f"Name Entry: Using fallback rival name '{fallback}'")

                elif name_type == "pokemon":
                    # Check if this is a starter nickname
                    if (
                        starter_planner.waiting_for_nickname
                        and starter_planner.nickname
                    ):
                        nickname = starter_planner.nickname
                        planner.set_current_starter_nickname(nickname)
                        planner.start_typing(nickname)
                        log.info(f"Name Entry: Typing starter nickname '{nickname}'")
                    else:
                        # No preplanned nickname - skip by pressing START
                        log.info("Name Entry: No preplanned nickname, skipping")
                        return "START;"
                else:
                    # Unknown name type - try to infer from context
                    # Default to LASS if player_name not yet typed, otherwise skip
                    log.warning(
                        f"Name Entry: Unknown name_type '{name_type}', defaulting to LASS"
                    )
                    planner.start_typing("LASS")

            # Get next step in typing sequence
            # Ensure we actually have a name to type before checking completion
            if (
                planner.current_name
                and planner.is_done_typing()
                and not planner.confirmation_sent
            ):
                log.info("Name typing complete. Confirming with START.")
                planner.set_confirmation_sent(True)

                # If this was a starter nickname, mark it as entered
                if (
                    planner.name_type == "pokemon"
                    and starter_planner.waiting_for_nickname
                ):
                    starter_planner.mark_nickname_entered()

                return "START;"

            # If already confirmed but state still active, wait (return None)
            if planner.confirmation_sent:
                return None

            step = planner.get_current_step()
            if step:
                log.debug(f"Name Entry Step: '{step['char']}' -> {step['path']}")
                return step["path"]

        return None

    def _check_starter_selection(self, state: Dict[str, Any]) -> Optional[str]:
        """
        Handle starter Pokemon selection when player is in position.

        This is called after the LLM has generated a starter choice and
        the player has navigated to the target position.
        """
        starter_planner = get_starter_planner()

        # Skip if no plan or already obtained
        if not starter_planner.has_plan or starter_planner._starter_obtained:
            return None

        # Check if we're in Oak's lab
        map_id = state.get("map_id", 0)
        if map_id != OAKS_LAB_MAP_ID:
            return None

        # Get player position
        player_x = state.get("world_x", 0)
        player_y = state.get("world_y", 0)

        # Check if at target position
        if starter_planner.is_at_target(player_x, player_y):
            facing = state.get("facing", "").lower()

            # If facing north and at target, press A to get starter
            if facing == "up" or facing == "north":
                log.info(
                    f"At starter position facing north - pressing A to get {starter_planner.species}"
                )
                return "A;"
            else:
                # Face north first
                log.info(f"At starter position but facing {facing} - turning north")
                return "U;"

        return None

    def _check_pallet_town_loop(self, state: Dict[str, Any], cycle_count: int):
        """Force navigation goal if stuck in Pallet Town loop."""
        map_name = state.get("map_name", "")
        if map_name == "PALLET_TOWN" and not self.navigation_controller.goal_stack:
            log.info("Pallet Town Loop Breaker: Setting forced goal to Route 1")
            self.navigation_controller.set_exit_goal(
                exit_coords=(10, 1),
                map_name=map_name,
                map_id=state.get("map_id", 0),
                destination="Route 1 (Go EAST around house!)",
                current_cycle=cycle_count,
            )

    def check_invisible_obstacle(
        self,
        state: Dict[str, Any],
        stuck_info: Dict[str, Any],
        minimap_analysis: Dict[str, Any],
    ) -> Tuple[bool, Optional[str]]:
        """
        Check for invisible obstacles (stuck but map says walkable).
        Returns (is_detected, hint_text).
        """
        if not stuck_info.get("is_stuck"):
            return False, None

        # Check walkability from minimap analysis (if available)
        walkable = True  # Default to true if unknown, to trigger check
        if minimap_analysis:
            pass

        # Determine facing to avoid map boundaries
        facing = state.get("facing", "down")

        return False, None  # Placeholder for now

    def set_starter_navigation_goal(
        self, state: Dict[str, Any], cycle_count: int
    ) -> bool:
        """
        Set navigation goal to the preplanned starter Pokemon position.

        Called by llmdriver when starter choice is made and navigation should begin.

        Returns:
            True if goal was set, False otherwise
        """
        starter_planner = get_starter_planner()

        if not starter_planner.has_plan:
            log.warning("Cannot set starter nav goal: no starter plan exists")
            return False

        if starter_planner._starter_obtained:
            log.info("Starter already obtained, no navigation needed")
            return False

        target_pos = starter_planner.target_position
        if not target_pos:
            log.error("Starter plan has no target position")
            return False

        map_name = state.get("map_name", "OAKS_LAB")
        map_id = state.get("map_id", OAKS_LAB_MAP_ID)

        # Set navigation goal to stand in front of the starter Pokeball
        self.navigation_controller.set_exit_goal(
            exit_coords=target_pos,
            map_name=map_name,
            map_id=map_id,
            destination=f"Get {starter_planner.species} from Pokeball",
            current_cycle=cycle_count,
        )

        starter_planner.mark_navigation_started()
        log.info(
            f"Navigation goal set to starter position {target_pos} for {starter_planner.species}"
        )

        return True

    def _check_photo_moments(self, state: Dict[str, Any]) -> None:
        """
        Check if player is at a scripted photo location and trigger photo moment.
        Uses configuration from game_data.yaml.
        """
        if not self._achievement_tracker:
            return

        map_id = state.get("map_id", 0)
        player_x = state.get("world_x", 0)
        player_y = state.get("world_y", 0)

        for moment_id, info in PHOTO_MOMENTS.items():
            # Skip if already triggered this session
            if moment_id in self._triggered_moments:
                continue

            # Skip if already in achievement tracker
            achievement_type = self._get_achievement_type(moment_id)
            if not achievement_type or self._achievement_tracker.is_triggered(
                achievement_type
            ):
                self._triggered_moments.add(moment_id)
                continue

            # Check Map ID (can be single ID or list)
            target_map_ids = info.get("map_ids") or [info.get("map_id")]
            if map_id not in target_map_ids:
                continue

            # Check specific triggers
            triggered = False

            if "position" in info:
                target_x, target_y = info["position"]
                dist_range = info.get("range", 2)
                if (
                    abs(player_x - target_x) <= dist_range
                    and abs(player_y - target_y) <= dist_range
                ):
                    triggered = True
            elif "min_x" in info:
                if player_x >= info["min_x"]:
                    triggered = True
            elif "min_y" in info:
                if player_y >= info["min_y"]:
                    triggered = True
            else:
                # Map entry only
                triggered = True

            if triggered:
                self._trigger_photo_moment(moment_id)
                self._triggered_moments.add(moment_id)

    def _get_achievement_type(self, name: str):
        """Get AchievementType enum from string name."""
        try:
            from trackers.achievement_tracker import AchievementType

            type_map = {
                # Original photo moments
                "route_1_flower": AchievementType.ROUTE_1_FLOWER,
                "viridian_forest_break": AchievementType.VIRIDIAN_FOREST_BREAK,
                # Story/location moments
                "ss_anne_deck": AchievementType.SS_ANNE_DECK,
                "pokemon_tower_spooked": AchievementType.POKEMON_TOWER_SPOOKED,
                "game_corner_slots": AchievementType.GAME_CORNER_SLOTS,
                "fighting_dojo": AchievementType.FIGHTING_DOJO,
                "safari_zone_explorer": AchievementType.SAFARI_ZONE_EXPLORER,
                "team_rocket_first": AchievementType.TEAM_ROCKET_FIRST,
                # Scenic/Nature moments
                "cerulean_cape": AchievementType.CERULEAN_CAPE,
                "mt_moon_exit": AchievementType.MT_MOON_EXIT,
                "rock_tunnel_exit": AchievementType.ROCK_TUNNEL_EXIT,
                "cycling_road": AchievementType.CYCLING_ROAD,
                "seafoam_islands": AchievementType.SEAFOAM_ISLANDS,
                "route_12_fishing": AchievementType.ROUTE_12_FISHING,
                # Milestone moments
                "pewter_gym_entrance": AchievementType.PEWTER_GYM_ENTRANCE,
                "indigo_plateau": AchievementType.INDIGO_PLATEAU,
                "daycare_visit": AchievementType.DAYCARE_VISIT,
            }
            return type_map.get(name)
        except ImportError:
            return None

    def _trigger_photo_moment(self, photo_type: str) -> None:
        """
        Trigger a scripted photo moment.

        This will:
        1. Trigger the achievement in the tracker
        2. Call the photo callback if set (for tweet generation)
        """
        log.info(f"📸 SCRIPTED PHOTO MOMENT: {photo_type}")

        achievement_type = self._get_achievement_type(photo_type)
        if not achievement_type:
            log.warning(f"Unknown photo type: {photo_type}")
            return

        # Trigger achievement
        if self._achievement_tracker:
            achievement = self._achievement_tracker.trigger_scripted_photo(
                achievement_type
            )
            if achievement and self._photo_callback:
                # Call the callback to trigger tweet generation
                try:
                    self._photo_callback(achievement)
                except Exception as e:
                    log.error(f"Photo callback error: {e}")
Functions
check_for_override(state, cycle_count)

Check game state for forced actions that bypass the LLM. Returns an action string (e.g. "A;", "START;") or None.

Source code in core/scenario_manager.py
def check_for_override(
    self, state: Dict[str, Any], cycle_count: int
) -> Optional[str]:
    """
    Check game state for forced actions that bypass the LLM.
    Returns an action string (e.g. "A;", "START;") or None.
    """
    # 1. Name Entry Override (handles player, rival, and pokemon nicknames)
    name_entry_action = self._check_name_entry(state)
    if name_entry_action:
        return name_entry_action

    # 2. Starter Selection Override (auto-navigate and select preplanned starter)
    starter_action = self._check_starter_selection(state)
    if starter_action:
        return starter_action

    # 3. Scripted Photo Moments
    self._check_photo_moments(state)

    # 4. Pallet Town Loop Breaker (Only if no goal exists)
    self._check_pallet_town_loop(state, cycle_count)

    return None
check_invisible_obstacle(state, stuck_info, minimap_analysis)

Check for invisible obstacles (stuck but map says walkable). Returns (is_detected, hint_text).

Source code in core/scenario_manager.py
def check_invisible_obstacle(
    self,
    state: Dict[str, Any],
    stuck_info: Dict[str, Any],
    minimap_analysis: Dict[str, Any],
) -> Tuple[bool, Optional[str]]:
    """
    Check for invisible obstacles (stuck but map says walkable).
    Returns (is_detected, hint_text).
    """
    if not stuck_info.get("is_stuck"):
        return False, None

    # Check walkability from minimap analysis (if available)
    walkable = True  # Default to true if unknown, to trigger check
    if minimap_analysis:
        pass

    # Determine facing to avoid map boundaries
    facing = state.get("facing", "down")

    return False, None  # Placeholder for now
set_achievement_tracker(tracker)

Set the achievement tracker reference for photo moment triggers.

Source code in core/scenario_manager.py
def set_achievement_tracker(self, tracker) -> None:
    """Set the achievement tracker reference for photo moment triggers."""
    self._achievement_tracker = tracker
set_photo_callback(callback)

Set callback function to call when a photo moment is triggered.

Source code in core/scenario_manager.py
def set_photo_callback(self, callback: Callable) -> None:
    """Set callback function to call when a photo moment is triggered."""
    self._photo_callback = callback
set_starter_navigation_goal(state, cycle_count)

Set navigation goal to the preplanned starter Pokemon position.

Called by llmdriver when starter choice is made and navigation should begin.

Returns:

Type Description
bool

True if goal was set, False otherwise

Source code in core/scenario_manager.py
def set_starter_navigation_goal(
    self, state: Dict[str, Any], cycle_count: int
) -> bool:
    """
    Set navigation goal to the preplanned starter Pokemon position.

    Called by llmdriver when starter choice is made and navigation should begin.

    Returns:
        True if goal was set, False otherwise
    """
    starter_planner = get_starter_planner()

    if not starter_planner.has_plan:
        log.warning("Cannot set starter nav goal: no starter plan exists")
        return False

    if starter_planner._starter_obtained:
        log.info("Starter already obtained, no navigation needed")
        return False

    target_pos = starter_planner.target_position
    if not target_pos:
        log.error("Starter plan has no target position")
        return False

    map_name = state.get("map_name", "OAKS_LAB")
    map_id = state.get("map_id", OAKS_LAB_MAP_ID)

    # Set navigation goal to stand in front of the starter Pokeball
    self.navigation_controller.set_exit_goal(
        exit_coords=target_pos,
        map_name=map_name,
        map_id=map_id,
        destination=f"Get {starter_planner.species} from Pokeball",
        current_cycle=cycle_count,
    )

    starter_planner.mark_navigation_started()
    log.info(
        f"Navigation goal set to starter position {target_pos} for {starter_planner.species}"
    )

    return True

Functions

core.navigation_controller

Navigation Controller - Goal-Oriented Navigation System

Orchestrates navigation goals, pathfinding, and stuck detection for the Pokemon LLM agent. Integrates with: - CoordinateTracker for position history and loop detection - MapVisitTracker for oscillation detection - MemoryManager for stuck detection and exit memory - BFS pathfinding from pyAIAgent/navigation.py

Key features: - Goal stack: Maintains primary and sub-goals that persist across cycles - Auto-path computation: Computes BFS path when goal is set - Stuck recovery: Suggests alternatives when stuck - Navigation context injection: Formats context for LLM prompts

Classes

GoalType

Bases: Enum

Types of navigation goals.

Source code in core/navigation_controller.py
class GoalType(Enum):
    """Types of navigation goals."""

    EXIT = "exit"  # Navigate to a specific exit
    COORDINATE = "coordinate"  # Navigate to a specific coordinate
    EXPLORE = "explore"  # Explore unexplored areas
    NPC = "npc"  # Interact with an NPC
    ITEM = "item"  # Pick up an item

NavigationController

Goal-oriented navigation controller.

Maintains navigation goals across cycles, computes paths, and provides navigation context for LLM injection.

Source code in core/navigation_controller.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
class NavigationController:
    """
    Goal-oriented navigation controller.

    Maintains navigation goals across cycles, computes paths, and provides
    navigation context for LLM injection.
    """

    STUCK_THRESHOLD = 10  # Cycles without progress before considered stuck (Increased from 5)
    MAX_GOAL_CYCLES = 50  # Max cycles before goal is considered stale
    PATH_RECOMPUTE_INTERVAL = 10  # Cycles between path recomputation

    def __init__(
        self,
        coord_tracker: CoordinateTracker,
        memory_manager: MemoryManager,
        map_visit_tracker: MapVisitTracker,
        storage_path: str = "data/navigation_state.json",
        reset_on_start: bool = False,
    ):
        """
        Initialize the navigation controller.

        Args:
            coord_tracker: CoordinateTracker for position history
            memory_manager: MemoryManager for stuck detection and exit memory
            map_visit_tracker: MapVisitTracker for oscillation detection
            storage_path: Path to persist navigation state
            reset_on_start: Whether to clear state on initialization
        """
        self.coord_tracker = coord_tracker
        self.memory_manager = memory_manager
        self.map_visit_tracker = map_visit_tracker
        self.storage_path = storage_path

        # Goal stack - primary goal and sub-goals
        self.goal_stack: List[NavigationGoal] = []

        # Position tracking for local stuck detection
        self.recent_positions: deque = deque(maxlen=10)
        self.cycles_at_same_position: int = 0

        # ROM path for pathfinding
        self.rom_path = get_rom_path() if HAS_PATHFINDING else None

        # Load or reset
        if not reset_on_start and os.path.exists(storage_path):
            self._load()
            log.info(f"🧭 Navigation controller loaded ({len(self.goal_stack)} goals)")
        else:
            log.info("🧭 Navigation controller initialized (fresh start)")

    def _load(self) -> None:
        """Load state from disk."""
        try:
            with open(self.storage_path, "r") as f:
                data = json.load(f)

            self.goal_stack = [
                NavigationGoal.from_dict(g) for g in data.get("goal_stack", [])
            ]

        except Exception as e:
            log.error(f"Failed to load navigation state: {e}")
            self.goal_stack = []

    def _save(self) -> None:
        """Save state to disk."""
        try:
            os.makedirs(os.path.dirname(self.storage_path), exist_ok=True)
            data = {
                "goal_stack": [g.to_dict() for g in self.goal_stack],
                "last_updated": datetime.now().isoformat(),
            }
            with open(self.storage_path, "w") as f:
                json.dump(data, f, indent=2)
        except Exception as e:
            log.error(f"Failed to save navigation state: {e}")

    def set_goal(
        self,
        goal_type: GoalType,
        target_x: int,
        target_y: int,
        map_name: str,
        map_id: int,
        reason: str,
        current_cycle: int,
        compute_path: bool = True,
    ) -> NavigationGoal:
        """
        Set a new navigation goal.

        Args:
            goal_type: Type of goal
            target_x: Target X coordinate
            target_y: Target Y coordinate
            map_name: Target map name
            map_id: Target map ID
            reason: Why we're navigating there
            current_cycle: Current cycle number
            compute_path: Whether to compute BFS path immediately

        Returns:
            The created NavigationGoal
        """
        goal = NavigationGoal(
            goal_type=goal_type,
            target_x=target_x,
            target_y=target_y,
            map_name=map_name,
            map_id=map_id,
            reason=reason,
            created_at=datetime.now().isoformat(),
            cycles_active=0,
        )

        # Clear existing goals of same type on same map (replace them)
        self.goal_stack = [
            g
            for g in self.goal_stack
            if not (g.map_name == map_name and g.goal_type == goal_type)
        ]

        # Add to front of stack (highest priority)
        self.goal_stack.insert(0, goal)

        log.info(
            f"🎯 Navigation goal set: {goal_type.value} -> ({target_x}, {target_y}) on {map_name}: {reason}"
        )

        # Compute path if requested
        if compute_path and HAS_PATHFINDING:
            self._compute_path_for_goal(goal, map_id, current_cycle)

        self._save()
        return goal

    def _compute_path_for_goal(
        self,
        goal: NavigationGoal,
        current_map_id: int,
        current_cycle: int,
        current_pos: Optional[Tuple[int, int]] = None,
    ) -> Optional[str]:
        """
        Compute BFS path to goal.

        Returns action string (e.g., "R;R;D;D;") or None if no path.
        """
        if not HAS_PATHFINDING:
            log.warning("Pathfinding not available (pyAIAgent.navigation not loaded)")
            return None

        if goal.map_id != current_map_id:
            log.debug(
                f"Cannot compute path: goal on different map (goal: {goal.map_id}, current: {current_map_id})"
            )
            return None

        # Get current position from coord_tracker if not provided
        if current_pos is None:
            if self.coord_tracker.history:
                latest = self.coord_tracker.history[0]
                current_pos = (latest.x, latest.y)
            else:
                log.warning("Cannot compute path: no current position available")
                return None

        try:
            # Check cache
            path_cache = get_path_cache()
            cached_path = path_cache.get(
                current_map_id, current_pos, (goal.target_x, goal.target_y)
            )

            if cached_path:
                log.info("Cache HIT")
                goal.computed_path = cached_path
                goal.path_computed_at = current_cycle
                return cached_path

            path = find_path(
                self.rom_path,
                current_map_id,
                list(current_pos),
                [goal.target_x, goal.target_y],
            )

            if path:
                path_cache.set(
                    current_map_id, current_pos, (goal.target_x, goal.target_y), path
                )
                goal.computed_path = path
                goal.path_computed_at = current_cycle
                log.info(
                    f"🛤️ Path computed: {path[:30]}... ({len(path.split(';')) - 1} moves)"
                )
            else:
                log.warning(f"No path found to ({goal.target_x}, {goal.target_y})")

            return path

        except Exception as e:
            log.error(f"Path computation failed: {e}")
            return None

    def update(
        self,
        current_cycle: int,
        current_pos: Tuple[int, int],
        current_map_name: str,
        current_map_id: int,
        position_history: List[Tuple[str, Tuple[int, int]]],
    ) -> NavigationState:
        """
        Update navigation state for this cycle.

        Called each cycle with current position. Updates goal progress,
        detects stuck state, and recomputes paths as needed.

        Args:
            current_cycle: Current cycle number
            current_pos: Current (x, y) position
            current_map_name: Current map name
            current_map_id: Current map ID
            position_history: Recent position history for stuck detection

        Returns:
            NavigationState with current status and suggestions
        """
        # Track position for local stuck detection
        self.recent_positions.append((current_map_name, current_pos))

        # Check for position stuckness
        if len(self.recent_positions) >= 3:
            last_three = list(self.recent_positions)[-3:]
            if all(pos == last_three[0] for pos in last_three):
                self.cycles_at_same_position += 1
            else:
                self.cycles_at_same_position = 0

        # Update active goals
        for goal in self.goal_stack:
            goal.cycles_active += 1

        # Prune stale goals
        self.goal_stack = [
            g for g in self.goal_stack if g.cycles_active < self.MAX_GOAL_CYCLES
        ]

        # Get current goal (if any)
        current_goal = self._get_active_goal(current_map_name)

        if not current_goal:
            # No goal - suggest exploration
            stuck_status = self._get_stuck_status(position_history)
            return NavigationState(
                has_goal=False,
                goal_description="No navigation goal set",
                path_available=False,
                next_moves=None,
                distance_to_goal=None,
                stuck_status=stuck_status,
                suggestion="Consider setting a navigation target (exit, NPC, or unexplored area)",
                force_random_action=(stuck_status == "stuck"),
            )

        # Check if goal reached
        distance = abs(current_pos[0] - current_goal.target_x) + abs(
            current_pos[1] - current_goal.target_y
        )

        if distance <= 1:
            log.info(f"✅ Goal reached: {current_goal.reason}")
            self.goal_stack.remove(current_goal)
            self._save()
            return NavigationState(
                has_goal=False,
                goal_description=f"Goal reached: {current_goal.reason}",
                path_available=False,
                next_moves=None,
                distance_to_goal=0,
                stuck_status="ok",
                suggestion="Goal reached! Set a new navigation target.",
                force_random_action=False,
            )

        # Recompute path if needed
        should_recompute = current_goal.computed_path is None or (
            current_goal.path_computed_at
            and current_cycle - current_goal.path_computed_at
            >= self.PATH_RECOMPUTE_INTERVAL
        )

        if should_recompute and HAS_PATHFINDING:
            self._compute_path_for_goal(
                current_goal, current_map_id, current_cycle, current_pos
            )

        # Get next moves from path
        next_moves = self._get_next_moves(current_goal.computed_path, num_moves=4)

        # Determine stuck status
        stuck_status = self._get_stuck_status(position_history)

        # Generate suggestion
        suggestion = self._generate_suggestion(
            current_goal, distance, stuck_status, next_moves
        )

        self._save()

        return NavigationState(
            has_goal=True,
            goal_description=f"{current_goal.goal_type.value}: {current_goal.reason}",
            path_available=current_goal.computed_path is not None,
            next_moves=next_moves,
            distance_to_goal=distance,
            stuck_status=stuck_status,
            suggestion=suggestion,
            force_random_action=(stuck_status == "stuck"),
        )

    def _get_active_goal(self, current_map_name: str) -> Optional[NavigationGoal]:
        """Get the active goal for current map (or cross-map goal)."""
        for goal in self.goal_stack:
            if goal.map_name == current_map_name:
                return goal

        # If no same-map goal, return first goal (cross-map navigation)
        return self.goal_stack[0] if self.goal_stack else None

    def _get_stuck_status(
        self, position_history: List[Tuple[str, Tuple[int, int]]]
    ) -> str:
        """Determine stuck status from position history."""
        # Check local stuck detector
        if self.cycles_at_same_position >= 5:
            return "stuck"
        elif self.cycles_at_same_position >= 3:
            return "slow_progress"

        # Check memory manager's stuck detection
        stuck_info = self.memory_manager.detect_stuck(position_history)
        if stuck_info.get("is_stuck"):
            return "stuck"

        # Check for oscillation
        oscillation = self.map_visit_tracker.detect_oscillation()
        if oscillation:
            return "oscillating"

        return "ok"

    def _get_next_moves(self, path: Optional[str], num_moves: int = 4) -> Optional[str]:
        """Extract next N moves from computed path."""
        if not path:
            return None

        moves = path.rstrip(";").split(";")
        if not moves:
            return None

        return ";".join(moves[:num_moves]) + ";"

    def _generate_suggestion(
        self,
        goal: NavigationGoal,
        distance: int,
        stuck_status: str,
        next_moves: Optional[str],
    ) -> str:
        """Generate navigation suggestion based on current state."""

        if stuck_status == "stuck":
            goal.failed_attempts += 1
            if goal.failed_attempts >= 3:
                return f"STUCK for {self.cycles_at_same_position}+ cycles trying to reach {goal.reason}. Consider abandoning this goal and trying a different route or target."
            else:
                return f"STUCK! Can't make progress toward {goal.reason}. Try pressing A to interact, or move in a different direction first."

        elif stuck_status == "oscillating":
            return f"MAP LOOP DETECTED! You keep entering and leaving maps. Focus on your goal: {goal.reason}"

        elif stuck_status == "slow_progress":
            return f"Making slow progress toward {goal.reason}. Check if you're blocked and try an alternate path."

        # Normal progress
        if next_moves:
            return f"FOLLOW PATH: {next_moves} ({distance} tiles to goal)"
        else:
            # No computed path - give direction hint
            return f"Navigate toward ({goal.target_x}, {goal.target_y}) - {distance} tiles away"

    def get_context_for_llm(self, current_map_name: str) -> str:
        """
        Generate navigation context string for LLM injection.

        Returns formatted context about current goal, path, and suggestions.
        """
        current_goal = self._get_active_goal(current_map_name)

        if not current_goal:
            return ""

        lines = [
            "═══════════════════════════════════════",
            "🧭 NAVIGATION GOAL",
            "═══════════════════════════════════════",
            f"TARGET: ({current_goal.target_x}, {current_goal.target_y}) on {current_goal.map_name}",
            f"REASON: {current_goal.reason}",
        ]

        if current_goal.computed_path:
            next_moves = self._get_next_moves(current_goal.computed_path, num_moves=5)
            lines.append(f"COMPUTED PATH: {next_moves}")
            lines.append(f"FOLLOW THIS PATH for reliable navigation!")

        lines.append(f"CYCLES ACTIVE: {current_goal.cycles_active}")

        if current_goal.failed_attempts > 0:
            lines.append(f"⚠️ FAILED ATTEMPTS: {current_goal.failed_attempts}")

        lines.append("═══════════════════════════════════════")

        return "\n".join(lines)

    def set_exit_goal(
        self,
        exit_coords: Tuple[int, int],
        map_name: str,
        map_id: int,
        destination: str,
        current_cycle: int,
    ) -> NavigationGoal:
        """
        Convenience method to set a goal to reach an exit tile.

        Args:
            exit_coords: (x, y) of exit tile
            map_name: Current map name
            map_id: Current map ID
            destination: Where the exit leads to
            current_cycle: Current cycle number
        """
        return self.set_goal(
            goal_type=GoalType.EXIT,
            target_x=exit_coords[0],
            target_y=exit_coords[1],
            map_name=map_name,
            map_id=map_id,
            reason=f"Exit to {destination}",
            current_cycle=current_cycle,
        )

    def clear_goals(self, map_name: Optional[str] = None) -> int:
        """
        Clear navigation goals.

        Args:
            map_name: If provided, only clear goals for this map.
                     If None, clear all goals.

        Returns:
            Number of goals cleared.
        """
        if map_name:
            original_count = len(self.goal_stack)
            self.goal_stack = [g for g in self.goal_stack if g.map_name != map_name]
            cleared = original_count - len(self.goal_stack)
        else:
            cleared = len(self.goal_stack)
            self.goal_stack = []

        if cleared > 0:
            log.info(f"🗑️ Cleared {cleared} navigation goal(s)")
            self._save()

        return cleared

    def record_goal_failure(self, reason: str) -> None:
        """
        Record that the current goal failed.

        This increases failed_attempts and may trigger goal abandonment suggestions.
        """
        if self.goal_stack:
            self.goal_stack[0].failed_attempts += 1
            log.warning(
                f"❌ Goal failure recorded: {reason} (attempts: {self.goal_stack[0].failed_attempts})"
            )
            self._save()
Functions
__init__(coord_tracker, memory_manager, map_visit_tracker, storage_path='data/navigation_state.json', reset_on_start=False)

Initialize the navigation controller.

Parameters:

Name Type Description Default
coord_tracker CoordinateTracker

CoordinateTracker for position history

required
memory_manager MemoryManager

MemoryManager for stuck detection and exit memory

required
map_visit_tracker MapVisitTracker

MapVisitTracker for oscillation detection

required
storage_path str

Path to persist navigation state

'data/navigation_state.json'
reset_on_start bool

Whether to clear state on initialization

False
Source code in core/navigation_controller.py
def __init__(
    self,
    coord_tracker: CoordinateTracker,
    memory_manager: MemoryManager,
    map_visit_tracker: MapVisitTracker,
    storage_path: str = "data/navigation_state.json",
    reset_on_start: bool = False,
):
    """
    Initialize the navigation controller.

    Args:
        coord_tracker: CoordinateTracker for position history
        memory_manager: MemoryManager for stuck detection and exit memory
        map_visit_tracker: MapVisitTracker for oscillation detection
        storage_path: Path to persist navigation state
        reset_on_start: Whether to clear state on initialization
    """
    self.coord_tracker = coord_tracker
    self.memory_manager = memory_manager
    self.map_visit_tracker = map_visit_tracker
    self.storage_path = storage_path

    # Goal stack - primary goal and sub-goals
    self.goal_stack: List[NavigationGoal] = []

    # Position tracking for local stuck detection
    self.recent_positions: deque = deque(maxlen=10)
    self.cycles_at_same_position: int = 0

    # ROM path for pathfinding
    self.rom_path = get_rom_path() if HAS_PATHFINDING else None

    # Load or reset
    if not reset_on_start and os.path.exists(storage_path):
        self._load()
        log.info(f"🧭 Navigation controller loaded ({len(self.goal_stack)} goals)")
    else:
        log.info("🧭 Navigation controller initialized (fresh start)")
clear_goals(map_name=None)

Clear navigation goals.

Parameters:

Name Type Description Default
map_name Optional[str]

If provided, only clear goals for this map. If None, clear all goals.

None

Returns:

Type Description
int

Number of goals cleared.

Source code in core/navigation_controller.py
def clear_goals(self, map_name: Optional[str] = None) -> int:
    """
    Clear navigation goals.

    Args:
        map_name: If provided, only clear goals for this map.
                 If None, clear all goals.

    Returns:
        Number of goals cleared.
    """
    if map_name:
        original_count = len(self.goal_stack)
        self.goal_stack = [g for g in self.goal_stack if g.map_name != map_name]
        cleared = original_count - len(self.goal_stack)
    else:
        cleared = len(self.goal_stack)
        self.goal_stack = []

    if cleared > 0:
        log.info(f"🗑️ Cleared {cleared} navigation goal(s)")
        self._save()

    return cleared
get_context_for_llm(current_map_name)

Generate navigation context string for LLM injection.

Returns formatted context about current goal, path, and suggestions.

Source code in core/navigation_controller.py
def get_context_for_llm(self, current_map_name: str) -> str:
    """
    Generate navigation context string for LLM injection.

    Returns formatted context about current goal, path, and suggestions.
    """
    current_goal = self._get_active_goal(current_map_name)

    if not current_goal:
        return ""

    lines = [
        "═══════════════════════════════════════",
        "🧭 NAVIGATION GOAL",
        "═══════════════════════════════════════",
        f"TARGET: ({current_goal.target_x}, {current_goal.target_y}) on {current_goal.map_name}",
        f"REASON: {current_goal.reason}",
    ]

    if current_goal.computed_path:
        next_moves = self._get_next_moves(current_goal.computed_path, num_moves=5)
        lines.append(f"COMPUTED PATH: {next_moves}")
        lines.append(f"FOLLOW THIS PATH for reliable navigation!")

    lines.append(f"CYCLES ACTIVE: {current_goal.cycles_active}")

    if current_goal.failed_attempts > 0:
        lines.append(f"⚠️ FAILED ATTEMPTS: {current_goal.failed_attempts}")

    lines.append("═══════════════════════════════════════")

    return "\n".join(lines)
record_goal_failure(reason)

Record that the current goal failed.

This increases failed_attempts and may trigger goal abandonment suggestions.

Source code in core/navigation_controller.py
def record_goal_failure(self, reason: str) -> None:
    """
    Record that the current goal failed.

    This increases failed_attempts and may trigger goal abandonment suggestions.
    """
    if self.goal_stack:
        self.goal_stack[0].failed_attempts += 1
        log.warning(
            f"❌ Goal failure recorded: {reason} (attempts: {self.goal_stack[0].failed_attempts})"
        )
        self._save()
set_exit_goal(exit_coords, map_name, map_id, destination, current_cycle)

Convenience method to set a goal to reach an exit tile.

Parameters:

Name Type Description Default
exit_coords Tuple[int, int]

(x, y) of exit tile

required
map_name str

Current map name

required
map_id int

Current map ID

required
destination str

Where the exit leads to

required
current_cycle int

Current cycle number

required
Source code in core/navigation_controller.py
def set_exit_goal(
    self,
    exit_coords: Tuple[int, int],
    map_name: str,
    map_id: int,
    destination: str,
    current_cycle: int,
) -> NavigationGoal:
    """
    Convenience method to set a goal to reach an exit tile.

    Args:
        exit_coords: (x, y) of exit tile
        map_name: Current map name
        map_id: Current map ID
        destination: Where the exit leads to
        current_cycle: Current cycle number
    """
    return self.set_goal(
        goal_type=GoalType.EXIT,
        target_x=exit_coords[0],
        target_y=exit_coords[1],
        map_name=map_name,
        map_id=map_id,
        reason=f"Exit to {destination}",
        current_cycle=current_cycle,
    )
set_goal(goal_type, target_x, target_y, map_name, map_id, reason, current_cycle, compute_path=True)

Set a new navigation goal.

Parameters:

Name Type Description Default
goal_type GoalType

Type of goal

required
target_x int

Target X coordinate

required
target_y int

Target Y coordinate

required
map_name str

Target map name

required
map_id int

Target map ID

required
reason str

Why we're navigating there

required
current_cycle int

Current cycle number

required
compute_path bool

Whether to compute BFS path immediately

True

Returns:

Type Description
NavigationGoal

The created NavigationGoal

Source code in core/navigation_controller.py
def set_goal(
    self,
    goal_type: GoalType,
    target_x: int,
    target_y: int,
    map_name: str,
    map_id: int,
    reason: str,
    current_cycle: int,
    compute_path: bool = True,
) -> NavigationGoal:
    """
    Set a new navigation goal.

    Args:
        goal_type: Type of goal
        target_x: Target X coordinate
        target_y: Target Y coordinate
        map_name: Target map name
        map_id: Target map ID
        reason: Why we're navigating there
        current_cycle: Current cycle number
        compute_path: Whether to compute BFS path immediately

    Returns:
        The created NavigationGoal
    """
    goal = NavigationGoal(
        goal_type=goal_type,
        target_x=target_x,
        target_y=target_y,
        map_name=map_name,
        map_id=map_id,
        reason=reason,
        created_at=datetime.now().isoformat(),
        cycles_active=0,
    )

    # Clear existing goals of same type on same map (replace them)
    self.goal_stack = [
        g
        for g in self.goal_stack
        if not (g.map_name == map_name and g.goal_type == goal_type)
    ]

    # Add to front of stack (highest priority)
    self.goal_stack.insert(0, goal)

    log.info(
        f"🎯 Navigation goal set: {goal_type.value} -> ({target_x}, {target_y}) on {map_name}: {reason}"
    )

    # Compute path if requested
    if compute_path and HAS_PATHFINDING:
        self._compute_path_for_goal(goal, map_id, current_cycle)

    self._save()
    return goal
update(current_cycle, current_pos, current_map_name, current_map_id, position_history)

Update navigation state for this cycle.

Called each cycle with current position. Updates goal progress, detects stuck state, and recomputes paths as needed.

Parameters:

Name Type Description Default
current_cycle int

Current cycle number

required
current_pos Tuple[int, int]

Current (x, y) position

required
current_map_name str

Current map name

required
current_map_id int

Current map ID

required
position_history List[Tuple[str, Tuple[int, int]]]

Recent position history for stuck detection

required

Returns:

Type Description
NavigationState

NavigationState with current status and suggestions

Source code in core/navigation_controller.py
def update(
    self,
    current_cycle: int,
    current_pos: Tuple[int, int],
    current_map_name: str,
    current_map_id: int,
    position_history: List[Tuple[str, Tuple[int, int]]],
) -> NavigationState:
    """
    Update navigation state for this cycle.

    Called each cycle with current position. Updates goal progress,
    detects stuck state, and recomputes paths as needed.

    Args:
        current_cycle: Current cycle number
        current_pos: Current (x, y) position
        current_map_name: Current map name
        current_map_id: Current map ID
        position_history: Recent position history for stuck detection

    Returns:
        NavigationState with current status and suggestions
    """
    # Track position for local stuck detection
    self.recent_positions.append((current_map_name, current_pos))

    # Check for position stuckness
    if len(self.recent_positions) >= 3:
        last_three = list(self.recent_positions)[-3:]
        if all(pos == last_three[0] for pos in last_three):
            self.cycles_at_same_position += 1
        else:
            self.cycles_at_same_position = 0

    # Update active goals
    for goal in self.goal_stack:
        goal.cycles_active += 1

    # Prune stale goals
    self.goal_stack = [
        g for g in self.goal_stack if g.cycles_active < self.MAX_GOAL_CYCLES
    ]

    # Get current goal (if any)
    current_goal = self._get_active_goal(current_map_name)

    if not current_goal:
        # No goal - suggest exploration
        stuck_status = self._get_stuck_status(position_history)
        return NavigationState(
            has_goal=False,
            goal_description="No navigation goal set",
            path_available=False,
            next_moves=None,
            distance_to_goal=None,
            stuck_status=stuck_status,
            suggestion="Consider setting a navigation target (exit, NPC, or unexplored area)",
            force_random_action=(stuck_status == "stuck"),
        )

    # Check if goal reached
    distance = abs(current_pos[0] - current_goal.target_x) + abs(
        current_pos[1] - current_goal.target_y
    )

    if distance <= 1:
        log.info(f"✅ Goal reached: {current_goal.reason}")
        self.goal_stack.remove(current_goal)
        self._save()
        return NavigationState(
            has_goal=False,
            goal_description=f"Goal reached: {current_goal.reason}",
            path_available=False,
            next_moves=None,
            distance_to_goal=0,
            stuck_status="ok",
            suggestion="Goal reached! Set a new navigation target.",
            force_random_action=False,
        )

    # Recompute path if needed
    should_recompute = current_goal.computed_path is None or (
        current_goal.path_computed_at
        and current_cycle - current_goal.path_computed_at
        >= self.PATH_RECOMPUTE_INTERVAL
    )

    if should_recompute and HAS_PATHFINDING:
        self._compute_path_for_goal(
            current_goal, current_map_id, current_cycle, current_pos
        )

    # Get next moves from path
    next_moves = self._get_next_moves(current_goal.computed_path, num_moves=4)

    # Determine stuck status
    stuck_status = self._get_stuck_status(position_history)

    # Generate suggestion
    suggestion = self._generate_suggestion(
        current_goal, distance, stuck_status, next_moves
    )

    self._save()

    return NavigationState(
        has_goal=True,
        goal_description=f"{current_goal.goal_type.value}: {current_goal.reason}",
        path_available=current_goal.computed_path is not None,
        next_moves=next_moves,
        distance_to_goal=distance,
        stuck_status=stuck_status,
        suggestion=suggestion,
        force_random_action=(stuck_status == "stuck"),
    )

NavigationGoal dataclass

A navigation goal with target and metadata.

Source code in core/navigation_controller.py
@dataclass
class NavigationGoal:
    """A navigation goal with target and metadata."""

    goal_type: GoalType
    target_x: int
    target_y: int
    map_name: str
    map_id: int
    reason: str
    created_at: str
    cycles_active: int = 0
    computed_path: Optional[str] = None  # BFS path as action string
    path_computed_at: Optional[int] = None  # Cycle when path was computed
    failed_attempts: int = 0

    def to_dict(self) -> dict:
        """Convert to dictionary for persistence."""
        d = asdict(self)
        d["goal_type"] = self.goal_type.value
        return d

    @classmethod
    def from_dict(cls, d: dict) -> "NavigationGoal":
        """Create from dictionary."""
        d["goal_type"] = GoalType(d["goal_type"])
        return cls(**d)
Functions
from_dict(d) classmethod

Create from dictionary.

Source code in core/navigation_controller.py
@classmethod
def from_dict(cls, d: dict) -> "NavigationGoal":
    """Create from dictionary."""
    d["goal_type"] = GoalType(d["goal_type"])
    return cls(**d)
to_dict()

Convert to dictionary for persistence.

Source code in core/navigation_controller.py
def to_dict(self) -> dict:
    """Convert to dictionary for persistence."""
    d = asdict(self)
    d["goal_type"] = self.goal_type.value
    return d

NavigationState dataclass

Current navigation state summary.

Source code in core/navigation_controller.py
@dataclass
class NavigationState:
    """Current navigation state summary."""

    has_goal: bool
    goal_description: str
    path_available: bool
    next_moves: Optional[str]  # Next 2-5 moves from computed path
    distance_to_goal: Optional[int]
    stuck_status: str  # "ok", "slow_progress", "stuck", "oscillating"
    suggestion: str
    force_random_action: bool = False

Functions

Game State Manager

core.game_state_manager

Functions

grid_to_world(grid_x, grid_y, player_grid_x, player_grid_y, player_world_x, player_world_y)

Convert minimap grid coordinates to world coordinates.

Parameters:

Name Type Description Default
grid_x int

X position in minimap grid

required
grid_y int

Y position in minimap grid

required
player_grid_x int

Player's X position in grid (usually center)

required
player_grid_y int

Player's Y position in grid (usually center)

required
player_world_x int

Player's world X coordinate

required
player_world_y int

Player's world Y coordinate

required

Returns:

Type Description
Tuple[int, int]

(world_x, world_y) tuple

Source code in core/game_state_manager.py
def grid_to_world(
    grid_x: int,
    grid_y: int,
    player_grid_x: int,
    player_grid_y: int,
    player_world_x: int,
    player_world_y: int,
) -> Tuple[int, int]:
    """
    Convert minimap grid coordinates to world coordinates.

    Args:
        grid_x: X position in minimap grid
        grid_y: Y position in minimap grid
        player_grid_x: Player's X position in grid (usually center)
        player_grid_y: Player's Y position in grid (usually center)
        player_world_x: Player's world X coordinate
        player_world_y: Player's world Y coordinate

    Returns:
        (world_x, world_y) tuple
    """
    # Offset from player in grid space
    dx = grid_x - player_grid_x
    dy = grid_y - player_grid_y
    # Apply to world space
    return (player_world_x + dx, player_world_y + dy)

parse_minimap(minimap_2d, world_position=None)

Pre-compute minimap analysis to reduce LLM hallucination.

Parameters:

Name Type Description Default
minimap_2d str

The raw minimap string

required
world_position Optional[list]

Player's world coordinates [x, y] - used to convert grid coords to world coords

None

Returns a dict with player position, grid dimensions, adjacent tiles, blocked directions, and world coordinate mappings for exits.

Source code in core/game_state_manager.py
def parse_minimap(minimap_2d: str, world_position: Optional[list] = None) -> dict:
    """
    Pre-compute minimap analysis to reduce LLM hallucination.

    Args:
        minimap_2d: The raw minimap string
        world_position: Player's world coordinates [x, y] - used to convert grid coords to world coords

    Returns a dict with player position, grid dimensions, adjacent tiles, blocked directions,
    and world coordinate mappings for exits.
    """
    if not minimap_2d:
        return {}

    # Parse rows (split by semicolon, strip whitespace)
    rows = [row.strip() for row in minimap_2d.split(";") if row.strip()]
    if not rows:
        return {}

    num_rows = len(rows)
    num_cols = len(rows[0]) if rows else 0

    # Find player position (look for 'P' in the grid)
    player_row = -1
    player_col = -1
    for r_idx, row in enumerate(rows):
        p_idx = row.find("P")
        if p_idx >= 0:
            player_row = r_idx
            player_col = p_idx
            break

    if player_row < 0 or player_col < 0:
        return {"error": "Player 'P' not found in minimap"}

    # World coordinate conversion function
    # Grid is centered on player, so player's grid position maps to world position
    world_x = world_position[0] if world_position and len(world_position) >= 2 else None
    world_y = world_position[1] if world_position and len(world_position) >= 2 else None

    def grid_to_world(grid_col, grid_row):
        """Convert grid coordinates to world coordinates"""
        if world_x is None or world_y is None:
            return None
        # Offset from player's grid position
        dx = grid_col - player_col
        dy = grid_row - player_row
        return [world_x + dx, world_y + dy]

    # Get adjacent tiles
    def get_tile(row, col):
        if 0 <= row < num_rows and 0 <= col < len(rows[row]):
            return rows[row][col]
        return "?"  # Out of bounds

    north_tile = get_tile(player_row - 1, player_col)
    south_tile = get_tile(player_row + 1, player_col)
    east_tile = get_tile(player_row, player_col + 1)
    west_tile = get_tile(player_row, player_col - 1)

    # Determine blocked directions (B = blocked, W = walkable, O = exit)
    def is_blocked(tile):
        return tile == "B"

    def is_exit(tile):
        return tile in ("O", "D", "E", ">", "<", "^", "v")

    def is_walkable(tile):
        return tile in ("W", "P", "O", "D", "E", ">", "<", "^", "v")

    blocked = []
    walkable = []
    exits = []

    if is_blocked(north_tile):
        blocked.append("NORTH (U)")
    elif is_exit(north_tile):
        exits.append(f"NORTH at [{player_col},{player_row - 1}]")
        walkable.append("NORTH (U)")
    else:
        # Assuming ? or W is walkable-ish or at least not strictly blocked
        if north_tile != "?":
            walkable.append("NORTH (U)")

    if is_blocked(south_tile):
        blocked.append("SOUTH (D)")
    elif is_exit(south_tile):
        exits.append(f"SOUTH at [{player_col},{player_row + 1}]")
        walkable.append("SOUTH (D)")
    else:
        if south_tile != "?":
            walkable.append("SOUTH (D)")

    if is_blocked(east_tile):
        blocked.append("EAST (R)")
    elif is_exit(east_tile):
        exits.append(f"EAST at [{player_col + 1},{player_row}]")
        walkable.append("EAST (R)")
    else:
        if east_tile != "?":
            walkable.append("EAST (R)")

    if is_blocked(west_tile):
        blocked.append("WEST (L)")
    elif is_exit(west_tile):
        exits.append(f"WEST at [{player_col - 1},{player_row}]")
        walkable.append("WEST (L)")
    else:
        if west_tile != "?":
            walkable.append("WEST (L)")

    # Find all O tiles in the minimap (with world coordinates and direction hints)
    o_tiles = []
    for r_idx, row in enumerate(rows):
        for c_idx, char in enumerate(row):
            if char in ("O", "D", "E", ">", "<", "^", "v"):
                world_coords = grid_to_world(c_idx, r_idx)

                # Add direction hint based on position relative to grid
                dir_hint = ""
                if r_idx >= num_rows - 2:  # Bottom edge
                    dir_hint = " (SOUTH EXIT - step DOWN)"
                elif r_idx <= 1:  # Top edge
                    dir_hint = " (NORTH - step UP to enter)"
                elif c_idx >= num_cols - 2:  # Right edge
                    dir_hint = " (EAST EXIT)"
                elif c_idx <= 1:  # Left edge
                    dir_hint = " (WEST EXIT)"

                if world_coords:
                    o_tiles.append(
                        f"Grid[{c_idx},{r_idx}] = World[{world_coords[0]},{world_coords[1]}]{dir_hint}"
                    )
                else:
                    o_tiles.append(f"Grid[{c_idx},{r_idx}]{dir_hint}")

    # Find all NPC tiles ('N') in the minimap
    npc_tiles = []
    for r_idx, row in enumerate(rows):
        for c_idx, char in enumerate(row):
            if char == "N":
                world_coords = grid_to_world(c_idx, r_idx)
                if world_coords:
                    npc_tiles.append(
                        f"Grid[{c_idx},{r_idx}] = World[{world_coords[0]},{world_coords[1]}]"
                    )
                else:
                    npc_tiles.append(f"Grid[{c_idx},{r_idx}]")

    # PASSAGE/CHOKEPOINT DETECTION
    passages = []  # List of detected passages/chokepoints

    # Scan for horizontal passages (W surrounded by B above and below)
    for r_idx in range(1, num_rows - 1):
        row = rows[r_idx]
        for c_idx in range(len(row)):
            tile = row[c_idx]
            if is_walkable(tile):
                above = get_tile(r_idx - 1, c_idx)
                below = get_tile(r_idx + 1, c_idx)

                # Horizontal passage: walkable with blocked above AND below
                if above == "B" and below == "B":
                    # Check if this is part of a longer passage
                    passage_width = 1
                    for check_col in range(c_idx + 1, min(c_idx + 10, len(row))):
                        if (
                            is_walkable(get_tile(r_idx, check_col))
                            and get_tile(r_idx - 1, check_col) == "B"
                            and get_tile(r_idx + 1, check_col) == "B"
                        ):
                            passage_width += 1
                        else:
                            break

                    if passage_width >= 2:  # At least 2 tiles wide to be notable
                        # Calculate direction from player
                        dy = r_idx - player_row
                        dx = c_idx - player_col
                        direction = []
                        if dy < 0:
                            direction.append("NORTH")
                        elif dy > 0:
                            direction.append("SOUTH")
                        if dx < 0:
                            direction.append("WEST")
                        elif dx > 0:
                            direction.append("EAST")
                        dir_str = "-".join(direction) if direction else "HERE"

                        passages.append(
                            {
                                "type": "horizontal_passage",
                                "start": f"[{c_idx},{r_idx}]",
                                "width": passage_width,
                                "direction": dir_str,
                                "distance": abs(dy) + abs(dx),
                            }
                        )
                        # Skip tiles we've already counted
                        break

    # Scan for vertical passages (W surrounded by B left and right)
    for c_idx in range(1, num_cols - 1):
        for r_idx in range(num_rows):
            if c_idx >= len(rows[r_idx]):
                continue
            tile = rows[r_idx][c_idx]
            if is_walkable(tile):
                left = get_tile(r_idx, c_idx - 1)
                right = get_tile(r_idx, c_idx + 1)

                # Vertical passage: walkable with blocked left AND right
                if left == "B" and right == "B":
                    # Check if this is part of a longer passage
                    passage_height = 1
                    for check_row in range(r_idx + 1, min(r_idx + 10, num_rows)):
                        if (
                            check_row < num_rows
                            and c_idx < len(rows[check_row])
                            and is_walkable(get_tile(check_row, c_idx))
                            and get_tile(check_row, c_idx - 1) == "B"
                            and get_tile(check_row, c_idx + 1) == "B"
                        ):
                            passage_height += 1
                        else:
                            break

                    if passage_height >= 2:  # At least 2 tiles tall to be notable
                        # Calculate direction from player
                        dy = r_idx - player_row
                        dx = c_idx - player_col
                        direction = []
                        if dy < 0:
                            direction.append("NORTH")
                        elif dy > 0:
                            direction.append("SOUTH")
                        if dx < 0:
                            direction.append("WEST")
                        elif dx > 0:
                            direction.append("EAST")
                        dir_str = "-".join(direction) if direction else "HERE"

                        passages.append(
                            {
                                "type": "vertical_passage",
                                "start": f"[{c_idx},{r_idx}]",
                                "height": passage_height,
                                "direction": dir_str,
                                "distance": abs(dy) + abs(dx),
                            }
                        )
                        break

    # Sort passages by distance from player
    passages.sort(key=lambda p: p.get("distance", 999))

    # Format passages for output (top 3 closest)
    passage_strs = []
    for p in passages[:3]:
        if p["type"] == "horizontal_passage":
            passage_strs.append(
                f"🌉 Horizontal path at {p['start']} ({p['width']} tiles wide) - go {p['direction']}"
            )
        else:
            passage_strs.append(
                f"🚶 Vertical path at {p['start']} ({p['height']} tiles tall) - go {p['direction']}"
            )

    return {
        "grid_size": f"{num_cols}x{num_rows}",
        "player_position": f"[{player_col},{player_row}]",
        "player_row": player_row,
        "player_col": player_col,
        "adjacent_tiles": {
            "north": f"[{player_col},{player_row - 1}] = '{north_tile}'",
            "south": f"[{player_col},{player_row + 1}] = '{south_tile}'",
            "east": f"[{player_col + 1},{player_row}] = '{east_tile}'",
            "west": f"[{player_col - 1},{player_row}] = '{west_tile}'",
        },
        "blocked_directions": blocked,
        "walkable_directions": walkable,
        "adjacent_exits": exits,
        "all_exit_tiles": o_tiles,
        "npc_tiles": npc_tiles,
        "passages": passage_strs,
    }

world_to_grid(world_x, world_y, player_grid_x, player_grid_y, player_world_x, player_world_y)

Convert world coordinates to minimap grid coordinates.

Parameters:

Name Type Description Default
world_x int

X position in world coordinates

required
world_y int

Y position in world coordinates

required
player_grid_x int

Player's X position in grid (usually center)

required
player_grid_y int

Player's Y position in grid (usually center)

required
player_world_x int

Player's world X coordinate

required
player_world_y int

Player's world Y coordinate

required

Returns:

Type Description
Tuple[int, int]

(grid_x, grid_y) tuple

Source code in core/game_state_manager.py
def world_to_grid(
    world_x: int,
    world_y: int,
    player_grid_x: int,
    player_grid_y: int,
    player_world_x: int,
    player_world_y: int,
) -> Tuple[int, int]:
    """
    Convert world coordinates to minimap grid coordinates.

    Args:
        world_x: X position in world coordinates
        world_y: Y position in world coordinates
        player_grid_x: Player's X position in grid (usually center)
        player_grid_y: Player's Y position in grid (usually center)
        player_world_x: Player's world X coordinate
        player_world_y: Player's world Y coordinate

    Returns:
        (grid_x, grid_y) tuple
    """
    # Offset from player in world space
    dx = world_x - player_world_x
    dy = world_y - player_world_y
    # Apply to grid space
    return (player_grid_x + dx, player_grid_y + dy)

Starter Planner

core.starter_planner

Starter Planner for Pokemon LLM Agent

Manages the pre-planned starter Pokemon selection: - Stores the AI's chosen starter (species + nickname) - Tracks position for navigation to the correct Pokeball - Coordinates with ScenarioManager for auto-execution

Classes

StarterPlan dataclass

Represents the complete starter selection plan.

Source code in core/starter_planner.py
@dataclass
class StarterPlan:
    """Represents the complete starter selection plan."""

    species: str  # CHARMANDER, SQUIRTLE, or BULBASAUR
    nickname: str  # Nickname for the Pokemon
    pokeball_pos: Tuple[int, int]  # Position of the Pokeball
    player_target_pos: Tuple[int, int]  # Where player should stand

    def __post_init__(self):
        """Validate and set positions."""
        if self.species not in STARTER_POSITIONS:
            raise ValueError(f"Invalid starter species: {self.species}")

        self.pokeball_pos = STARTER_POSITIONS[self.species]
        # Player stands 1 tile south of Pokeball (higher Y)
        self.player_target_pos = (self.pokeball_pos[0], self.pokeball_pos[1] + 1)
Functions
__post_init__()

Validate and set positions.

Source code in core/starter_planner.py
def __post_init__(self):
    """Validate and set positions."""
    if self.species not in STARTER_POSITIONS:
        raise ValueError(f"Invalid starter species: {self.species}")

    self.pokeball_pos = STARTER_POSITIONS[self.species]
    # Player stands 1 tile south of Pokeball (higher Y)
    self.player_target_pos = (self.pokeball_pos[0], self.pokeball_pos[1] + 1)

StarterPlanner

Manages starter Pokemon selection planning and execution tracking.

Workflow: 1. LLM generates starter choice (species + nickname) when entering Oak's lab 2. StarterPlanner stores the choice and computes target position 3. ScenarioManager uses A* pathfinding to navigate player to position 4. When at position, facing north + A triggers Pokeball interaction 5. After obtaining starter, nickname entry screen appears 6. NamePlanner handles auto-typing the pre-planned nickname

Source code in core/starter_planner.py
class StarterPlanner:
    """
    Manages starter Pokemon selection planning and execution tracking.

    Workflow:
    1. LLM generates starter choice (species + nickname) when entering Oak's lab
    2. StarterPlanner stores the choice and computes target position
    3. ScenarioManager uses A* pathfinding to navigate player to position
    4. When at position, facing north + A triggers Pokeball interaction
    5. After obtaining starter, nickname entry screen appears
    6. NamePlanner handles auto-typing the pre-planned nickname
    """

    def __init__(self):
        self._plan: Optional[StarterPlan] = None

        # Execution state
        self._navigation_started: bool = False
        self._at_target_position: bool = False
        self._starter_obtained: bool = False
        self._waiting_for_nickname: bool = False
        self._nickname_entered: bool = False

        # Track if choice was made this run (for persistence)
        self._choice_made_this_run: bool = False

    @property
    def has_plan(self) -> bool:
        """Check if a starter plan exists."""
        return self._plan is not None

    @property
    def plan(self) -> Optional[StarterPlan]:
        """Get the current starter plan."""
        return self._plan

    @property
    def species(self) -> Optional[str]:
        """Get the planned starter species."""
        return self._plan.species if self._plan else None

    @property
    def nickname(self) -> Optional[str]:
        """Get the planned starter nickname."""
        return self._plan.nickname if self._plan else None

    @property
    def target_position(self) -> Optional[Tuple[int, int]]:
        """Get the target position for the player to stand."""
        return self._plan.player_target_pos if self._plan else None

    @property
    def pokeball_position(self) -> Optional[Tuple[int, int]]:
        """Get the Pokeball position."""
        return self._plan.pokeball_pos if self._plan else None

    @property
    def is_complete(self) -> bool:
        """Check if starter selection is fully complete (obtained + nicknamed)."""
        return self._starter_obtained and self._nickname_entered

    @property
    def waiting_for_nickname(self) -> bool:
        """Check if waiting for nickname entry screen."""
        return self._waiting_for_nickname

    def set_choice(self, species: str, nickname: str) -> bool:
        """
        Set the starter Pokemon choice.

        Args:
            species: CHARMANDER, SQUIRTLE, or BULBASAUR
            nickname: Nickname for the Pokemon (max 10 chars, uppercase)

        Returns:
            True if valid choice set, False otherwise
        """
        species = species.upper().strip()
        nickname = nickname.upper().strip()[:10]

        if species not in STARTER_POSITIONS:
            log.error(f"Invalid starter species: {species}")
            return False

        if not nickname:
            log.warning("Empty nickname provided, using species name as fallback")
            nickname = species[:10]

        self._plan = StarterPlan(
            species=species,
            nickname=nickname,
            pokeball_pos=STARTER_POSITIONS[species],
            player_target_pos=(
                STARTER_POSITIONS[species][0],
                STARTER_POSITIONS[species][1] + 1,
            ),
        )

        self._choice_made_this_run = True
        self._navigation_started = False
        self._at_target_position = False
        self._starter_obtained = False
        self._waiting_for_nickname = False
        self._nickname_entered = False

        log.info(
            f"Starter plan set: {species} nicknamed '{nickname}' "
            f"(target pos: {self._plan.player_target_pos})"
        )
        return True

    def is_at_target(self, player_x: int, player_y: int) -> bool:
        """
        Check if player is at the target position for starter selection.

        Args:
            player_x: Current player world X coordinate
            player_y: Current player world Y coordinate

        Returns:
            True if at target position
        """
        if not self._plan:
            return False

        target_x, target_y = self._plan.player_target_pos
        at_target = player_x == target_x and player_y == target_y

        if at_target and not self._at_target_position:
            self._at_target_position = True
            log.info(
                f"Player reached starter target position: ({player_x}, {player_y})"
            )

        return at_target

    def mark_navigation_started(self):
        """Mark that navigation to starter has begun."""
        self._navigation_started = True
        log.info("Starter navigation started")

    def mark_starter_obtained(self):
        """Mark that the starter Pokemon was obtained."""
        self._starter_obtained = True
        self._waiting_for_nickname = True
        log.info(f"Starter {self.species} obtained! Waiting for nickname entry...")

    def mark_nickname_entered(self):
        """Mark that the nickname was entered."""
        self._nickname_entered = True
        self._waiting_for_nickname = False
        log.info(f"Nickname '{self.nickname}' entered! Starter selection complete.")

    def get_nav_context(self, player_x: int, player_y: int) -> str:
        """
        Get navigation context for LLM about starter selection.

        Args:
            player_x: Current player X position
            player_y: Current player Y position

        Returns:
            Formatted string for LLM context
        """
        if not self._plan:
            return ""

        target_x, target_y = self._plan.player_target_pos
        dx = target_x - player_x
        dy = target_y - player_y
        distance = abs(dx) + abs(dy)

        if self._at_target_position or distance == 0:
            return (
                f"STARTER SELECTION - AT TARGET POSITION!\n"
                f"You chose: {self._plan.species} (nickname: {self._plan.nickname})\n"
                f"Face NORTH and press A to pick up the Pokeball!\n"
            )
        else:
            direction_hints = []
            if dx > 0:
                direction_hints.append(f"go RIGHT {dx}")
            elif dx < 0:
                direction_hints.append(f"go LEFT {abs(dx)}")
            if dy > 0:
                direction_hints.append(f"go DOWN {dy}")
            elif dy < 0:
                direction_hints.append(f"go UP {abs(dy)}")

            return (
                f"STARTER SELECTION - NAVIGATE TO POKEBALL\n"
                f"You chose: {self._plan.species} (nickname: {self._plan.nickname})\n"
                f"Target position: ({target_x}, {target_y})\n"
                f"Your position: ({player_x}, {player_y})\n"
                f"Distance: {distance} tiles ({', '.join(direction_hints)})\n"
            )

    def reset(self):
        """Reset all state (for new run or testing)."""
        self._plan = None
        self._navigation_started = False
        self._at_target_position = False
        self._starter_obtained = False
        self._waiting_for_nickname = False
        self._nickname_entered = False
        self._choice_made_this_run = False
        log.info("StarterPlanner reset")

    def to_dict(self) -> dict:
        """Serialize state for persistence."""
        return {
            "species": self._plan.species if self._plan else None,
            "nickname": self._plan.nickname if self._plan else None,
            "navigation_started": self._navigation_started,
            "at_target_position": self._at_target_position,
            "starter_obtained": self._starter_obtained,
            "waiting_for_nickname": self._waiting_for_nickname,
            "nickname_entered": self._nickname_entered,
            "choice_made_this_run": self._choice_made_this_run,
        }

    def from_dict(self, data: dict):
        """Restore state from persistence."""
        species = data.get("species")
        nickname = data.get("nickname")

        if species and nickname:
            self.set_choice(species, nickname)

        self._navigation_started = data.get("navigation_started", False)
        self._at_target_position = data.get("at_target_position", False)
        self._starter_obtained = data.get("starter_obtained", False)
        self._waiting_for_nickname = data.get("waiting_for_nickname", False)
        self._nickname_entered = data.get("nickname_entered", False)
        self._choice_made_this_run = data.get("choice_made_this_run", False)

        log.info(f"StarterPlanner restored: {self.to_dict()}")
Attributes
has_plan property

Check if a starter plan exists.

is_complete property

Check if starter selection is fully complete (obtained + nicknamed).

nickname property

Get the planned starter nickname.

plan property

Get the current starter plan.

pokeball_position property

Get the Pokeball position.

species property

Get the planned starter species.

target_position property

Get the target position for the player to stand.

waiting_for_nickname property

Check if waiting for nickname entry screen.

Functions
from_dict(data)

Restore state from persistence.

Source code in core/starter_planner.py
def from_dict(self, data: dict):
    """Restore state from persistence."""
    species = data.get("species")
    nickname = data.get("nickname")

    if species and nickname:
        self.set_choice(species, nickname)

    self._navigation_started = data.get("navigation_started", False)
    self._at_target_position = data.get("at_target_position", False)
    self._starter_obtained = data.get("starter_obtained", False)
    self._waiting_for_nickname = data.get("waiting_for_nickname", False)
    self._nickname_entered = data.get("nickname_entered", False)
    self._choice_made_this_run = data.get("choice_made_this_run", False)

    log.info(f"StarterPlanner restored: {self.to_dict()}")
get_nav_context(player_x, player_y)

Get navigation context for LLM about starter selection.

Parameters:

Name Type Description Default
player_x int

Current player X position

required
player_y int

Current player Y position

required

Returns:

Type Description
str

Formatted string for LLM context

Source code in core/starter_planner.py
def get_nav_context(self, player_x: int, player_y: int) -> str:
    """
    Get navigation context for LLM about starter selection.

    Args:
        player_x: Current player X position
        player_y: Current player Y position

    Returns:
        Formatted string for LLM context
    """
    if not self._plan:
        return ""

    target_x, target_y = self._plan.player_target_pos
    dx = target_x - player_x
    dy = target_y - player_y
    distance = abs(dx) + abs(dy)

    if self._at_target_position or distance == 0:
        return (
            f"STARTER SELECTION - AT TARGET POSITION!\n"
            f"You chose: {self._plan.species} (nickname: {self._plan.nickname})\n"
            f"Face NORTH and press A to pick up the Pokeball!\n"
        )
    else:
        direction_hints = []
        if dx > 0:
            direction_hints.append(f"go RIGHT {dx}")
        elif dx < 0:
            direction_hints.append(f"go LEFT {abs(dx)}")
        if dy > 0:
            direction_hints.append(f"go DOWN {dy}")
        elif dy < 0:
            direction_hints.append(f"go UP {abs(dy)}")

        return (
            f"STARTER SELECTION - NAVIGATE TO POKEBALL\n"
            f"You chose: {self._plan.species} (nickname: {self._plan.nickname})\n"
            f"Target position: ({target_x}, {target_y})\n"
            f"Your position: ({player_x}, {player_y})\n"
            f"Distance: {distance} tiles ({', '.join(direction_hints)})\n"
        )
is_at_target(player_x, player_y)

Check if player is at the target position for starter selection.

Parameters:

Name Type Description Default
player_x int

Current player world X coordinate

required
player_y int

Current player world Y coordinate

required

Returns:

Type Description
bool

True if at target position

Source code in core/starter_planner.py
def is_at_target(self, player_x: int, player_y: int) -> bool:
    """
    Check if player is at the target position for starter selection.

    Args:
        player_x: Current player world X coordinate
        player_y: Current player world Y coordinate

    Returns:
        True if at target position
    """
    if not self._plan:
        return False

    target_x, target_y = self._plan.player_target_pos
    at_target = player_x == target_x and player_y == target_y

    if at_target and not self._at_target_position:
        self._at_target_position = True
        log.info(
            f"Player reached starter target position: ({player_x}, {player_y})"
        )

    return at_target
mark_navigation_started()

Mark that navigation to starter has begun.

Source code in core/starter_planner.py
def mark_navigation_started(self):
    """Mark that navigation to starter has begun."""
    self._navigation_started = True
    log.info("Starter navigation started")
mark_nickname_entered()

Mark that the nickname was entered.

Source code in core/starter_planner.py
def mark_nickname_entered(self):
    """Mark that the nickname was entered."""
    self._nickname_entered = True
    self._waiting_for_nickname = False
    log.info(f"Nickname '{self.nickname}' entered! Starter selection complete.")
mark_starter_obtained()

Mark that the starter Pokemon was obtained.

Source code in core/starter_planner.py
def mark_starter_obtained(self):
    """Mark that the starter Pokemon was obtained."""
    self._starter_obtained = True
    self._waiting_for_nickname = True
    log.info(f"Starter {self.species} obtained! Waiting for nickname entry...")
reset()

Reset all state (for new run or testing).

Source code in core/starter_planner.py
def reset(self):
    """Reset all state (for new run or testing)."""
    self._plan = None
    self._navigation_started = False
    self._at_target_position = False
    self._starter_obtained = False
    self._waiting_for_nickname = False
    self._nickname_entered = False
    self._choice_made_this_run = False
    log.info("StarterPlanner reset")
set_choice(species, nickname)

Set the starter Pokemon choice.

Parameters:

Name Type Description Default
species str

CHARMANDER, SQUIRTLE, or BULBASAUR

required
nickname str

Nickname for the Pokemon (max 10 chars, uppercase)

required

Returns:

Type Description
bool

True if valid choice set, False otherwise

Source code in core/starter_planner.py
def set_choice(self, species: str, nickname: str) -> bool:
    """
    Set the starter Pokemon choice.

    Args:
        species: CHARMANDER, SQUIRTLE, or BULBASAUR
        nickname: Nickname for the Pokemon (max 10 chars, uppercase)

    Returns:
        True if valid choice set, False otherwise
    """
    species = species.upper().strip()
    nickname = nickname.upper().strip()[:10]

    if species not in STARTER_POSITIONS:
        log.error(f"Invalid starter species: {species}")
        return False

    if not nickname:
        log.warning("Empty nickname provided, using species name as fallback")
        nickname = species[:10]

    self._plan = StarterPlan(
        species=species,
        nickname=nickname,
        pokeball_pos=STARTER_POSITIONS[species],
        player_target_pos=(
            STARTER_POSITIONS[species][0],
            STARTER_POSITIONS[species][1] + 1,
        ),
    )

    self._choice_made_this_run = True
    self._navigation_started = False
    self._at_target_position = False
    self._starter_obtained = False
    self._waiting_for_nickname = False
    self._nickname_entered = False

    log.info(
        f"Starter plan set: {species} nicknamed '{nickname}' "
        f"(target pos: {self._plan.player_target_pos})"
    )
    return True
to_dict()

Serialize state for persistence.

Source code in core/starter_planner.py
def to_dict(self) -> dict:
    """Serialize state for persistence."""
    return {
        "species": self._plan.species if self._plan else None,
        "nickname": self._plan.nickname if self._plan else None,
        "navigation_started": self._navigation_started,
        "at_target_position": self._at_target_position,
        "starter_obtained": self._starter_obtained,
        "waiting_for_nickname": self._waiting_for_nickname,
        "nickname_entered": self._nickname_entered,
        "choice_made_this_run": self._choice_made_this_run,
    }

Functions

get_starter_planner()

Get the global StarterPlanner instance.

Source code in core/starter_planner.py
def get_starter_planner() -> StarterPlanner:
    """Get the global StarterPlanner instance."""
    global _starter_planner_instance
    if _starter_planner_instance is None:
        _starter_planner_instance = StarterPlanner()
    return _starter_planner_instance

reset_starter_planner()

Reset the global StarterPlanner instance.

Source code in core/starter_planner.py
def reset_starter_planner():
    """Reset the global StarterPlanner instance."""
    global _starter_planner_instance
    if _starter_planner_instance:
        _starter_planner_instance.reset()
    _starter_planner_instance = None

Path Cache

core.path_cache

Path Cache - LRU cache for pathfinding results

Caches computed paths to avoid redundant pathfinding calculations. Significantly improves performance when LLM requests same paths repeatedly.

Classes

PathCache

LRU (Least Recently Used) cache for pathfinding results.

Stores paths with automatic eviction and TTL (time-to-live). Thread-safe for concurrent access.

Usage

cache = PathCache(max_size=100, ttl=300)

Try to get cached path

path = cache.get(map_id, start, goal) if path is None: # Compute path path = compute_path(...) cache.set(map_id, start, goal, path)

Source code in core/path_cache.py
class PathCache:
    """
    LRU (Least Recently Used) cache for pathfinding results.

    Stores paths with automatic eviction and TTL (time-to-live).
    Thread-safe for concurrent access.

    Usage:
        cache = PathCache(max_size=100, ttl=300)

        # Try to get cached path
        path = cache.get(map_id, start, goal)
        if path is None:
            # Compute path
            path = compute_path(...)
            cache.set(map_id, start, goal, path)
    """

    def __init__(self, max_size: int = 100, ttl: int = 300):
        """
        Initialize path cache.

        Args:
            max_size: Maximum number of paths to cache (LRU eviction)
            ttl: Time-to-live in seconds (paths older than this are invalid)
        """
        self.cache = OrderedDict()  # Preserves insertion order for LRU
        self.max_size = max_size
        self.ttl = ttl

        # Statistics
        self.hits = 0
        self.misses = 0
        self.evictions = 0

        log.info(f"PathCache initialized: max_size={max_size}, ttl={ttl}s")

    def get(
        self, map_id: int, start: Tuple[int, int], goal: Tuple[int, int]
    ) -> Optional[str]:
        """
        Get cached path if it exists and is still valid.

        Args:
            map_id: Map identifier
            start: Starting coordinates (x, y)
            goal: Goal coordinates (x, y)

        Returns:
            Cached path string or None if not cached/expired
        """
        key = self._make_key(map_id, start, goal)

        if key in self.cache:
            path, timestamp = self.cache[key]

            # Check if expired
            if time.time() - timestamp < self.ttl:
                # Move to end (most recently used)
                self.cache.move_to_end(key)
                self.hits += 1
                log.debug(f"Cache HIT: {key}")
                return path
            else:
                # Expired - remove it
                del self.cache[key]
                log.debug(f"Cache EXPIRED: {key}")

        self.misses += 1
        log.debug(f"Cache MISS: {key}")
        return None

    def set(
        self, map_id: int, start: Tuple[int, int], goal: Tuple[int, int], path: str
    ):
        """
        Store path in cache.

        Args:
            map_id: Map identifier
            start: Starting coordinates (x, y)
            goal: Goal coordinates (x, y)
            path: Path string to cache (e.g., "R;R;D;D;")
        """
        key = self._make_key(map_id, start, goal)

        # Update if exists, add if new
        if key in self.cache:
            self.cache.move_to_end(key)

        self.cache[key] = (path, time.time())

        # Evict oldest if over size
        if len(self.cache) > self.max_size:
            evicted_key = self.cache.popitem(last=False)  # Remove oldest
            self.evictions += 1
            log.debug(f"Cache EVICT: {evicted_key[0]} (size={self.max_size})")

        log.debug(f"Cache SET: {key}")

    def invalidate_map(self, map_id: int) -> int:
        """
        Invalidate all cached paths for a specific map.

        Call this when a map changes (e.g., NPC moves, boulder pushed).

        Args:
            map_id: Map identifier to invalidate

        Returns:
            Number of paths invalidated
        """
        keys_to_remove = [k for k in self.cache.keys() if k[0] == map_id]

        for key in keys_to_remove:
            del self.cache[key]

        if keys_to_remove:
            log.info(f"Invalidated {len(keys_to_remove)} paths for map {map_id}")

        return len(keys_to_remove)

    def clear(self):
        """Clear entire cache."""
        count = len(self.cache)
        self.cache.clear()
        log.info(f"Cache cleared: {count} paths removed")

    def get_stats(self) -> dict:
        """
        Get cache statistics.

        Returns:
            Dict with hits, misses, hit_rate, size, evictions
        """
        total = self.hits + self.misses
        hit_rate = ((self.hits / total) * 100) if total > 0 else 0

        return {
            "hits": self.hits,
            "misses": self.misses,
            "hit_rate": hit_rate,
            "size": len(self.cache),
            "max_size": self.max_size,
            "evictions": self.evictions,
        }

    def log_stats(self):
        """Log cache statistics."""
        stats = self.get_stats()
        log.info(
            f"PathCache stats: {stats['hits']} hits, {stats['misses']} misses, "
            f"{stats['hit_rate']:.1f}% hit rate, {stats['size']}/{stats['max_size']} cached"
        )

    @staticmethod
    def _make_key(map_id: int, start: Tuple[int, int], goal: Tuple[int, int]) -> tuple:
        """
        Create cache key from parameters.

        Args:
            map_id: Map identifier
            start: Starting coordinates
            goal: Goal coordinates

        Returns:
            Tuple key for cache dictionary
        """
        return (map_id, tuple(start), tuple(goal))
Functions
__init__(max_size=100, ttl=300)

Initialize path cache.

Parameters:

Name Type Description Default
max_size int

Maximum number of paths to cache (LRU eviction)

100
ttl int

Time-to-live in seconds (paths older than this are invalid)

300
Source code in core/path_cache.py
def __init__(self, max_size: int = 100, ttl: int = 300):
    """
    Initialize path cache.

    Args:
        max_size: Maximum number of paths to cache (LRU eviction)
        ttl: Time-to-live in seconds (paths older than this are invalid)
    """
    self.cache = OrderedDict()  # Preserves insertion order for LRU
    self.max_size = max_size
    self.ttl = ttl

    # Statistics
    self.hits = 0
    self.misses = 0
    self.evictions = 0

    log.info(f"PathCache initialized: max_size={max_size}, ttl={ttl}s")
clear()

Clear entire cache.

Source code in core/path_cache.py
def clear(self):
    """Clear entire cache."""
    count = len(self.cache)
    self.cache.clear()
    log.info(f"Cache cleared: {count} paths removed")
get(map_id, start, goal)

Get cached path if it exists and is still valid.

Parameters:

Name Type Description Default
map_id int

Map identifier

required
start Tuple[int, int]

Starting coordinates (x, y)

required
goal Tuple[int, int]

Goal coordinates (x, y)

required

Returns:

Type Description
Optional[str]

Cached path string or None if not cached/expired

Source code in core/path_cache.py
def get(
    self, map_id: int, start: Tuple[int, int], goal: Tuple[int, int]
) -> Optional[str]:
    """
    Get cached path if it exists and is still valid.

    Args:
        map_id: Map identifier
        start: Starting coordinates (x, y)
        goal: Goal coordinates (x, y)

    Returns:
        Cached path string or None if not cached/expired
    """
    key = self._make_key(map_id, start, goal)

    if key in self.cache:
        path, timestamp = self.cache[key]

        # Check if expired
        if time.time() - timestamp < self.ttl:
            # Move to end (most recently used)
            self.cache.move_to_end(key)
            self.hits += 1
            log.debug(f"Cache HIT: {key}")
            return path
        else:
            # Expired - remove it
            del self.cache[key]
            log.debug(f"Cache EXPIRED: {key}")

    self.misses += 1
    log.debug(f"Cache MISS: {key}")
    return None
get_stats()

Get cache statistics.

Returns:

Type Description
dict

Dict with hits, misses, hit_rate, size, evictions

Source code in core/path_cache.py
def get_stats(self) -> dict:
    """
    Get cache statistics.

    Returns:
        Dict with hits, misses, hit_rate, size, evictions
    """
    total = self.hits + self.misses
    hit_rate = ((self.hits / total) * 100) if total > 0 else 0

    return {
        "hits": self.hits,
        "misses": self.misses,
        "hit_rate": hit_rate,
        "size": len(self.cache),
        "max_size": self.max_size,
        "evictions": self.evictions,
    }
invalidate_map(map_id)

Invalidate all cached paths for a specific map.

Call this when a map changes (e.g., NPC moves, boulder pushed).

Parameters:

Name Type Description Default
map_id int

Map identifier to invalidate

required

Returns:

Type Description
int

Number of paths invalidated

Source code in core/path_cache.py
def invalidate_map(self, map_id: int) -> int:
    """
    Invalidate all cached paths for a specific map.

    Call this when a map changes (e.g., NPC moves, boulder pushed).

    Args:
        map_id: Map identifier to invalidate

    Returns:
        Number of paths invalidated
    """
    keys_to_remove = [k for k in self.cache.keys() if k[0] == map_id]

    for key in keys_to_remove:
        del self.cache[key]

    if keys_to_remove:
        log.info(f"Invalidated {len(keys_to_remove)} paths for map {map_id}")

    return len(keys_to_remove)
log_stats()

Log cache statistics.

Source code in core/path_cache.py
def log_stats(self):
    """Log cache statistics."""
    stats = self.get_stats()
    log.info(
        f"PathCache stats: {stats['hits']} hits, {stats['misses']} misses, "
        f"{stats['hit_rate']:.1f}% hit rate, {stats['size']}/{stats['max_size']} cached"
    )
set(map_id, start, goal, path)

Store path in cache.

Parameters:

Name Type Description Default
map_id int

Map identifier

required
start Tuple[int, int]

Starting coordinates (x, y)

required
goal Tuple[int, int]

Goal coordinates (x, y)

required
path str

Path string to cache (e.g., "R;R;D;D;")

required
Source code in core/path_cache.py
def set(
    self, map_id: int, start: Tuple[int, int], goal: Tuple[int, int], path: str
):
    """
    Store path in cache.

    Args:
        map_id: Map identifier
        start: Starting coordinates (x, y)
        goal: Goal coordinates (x, y)
        path: Path string to cache (e.g., "R;R;D;D;")
    """
    key = self._make_key(map_id, start, goal)

    # Update if exists, add if new
    if key in self.cache:
        self.cache.move_to_end(key)

    self.cache[key] = (path, time.time())

    # Evict oldest if over size
    if len(self.cache) > self.max_size:
        evicted_key = self.cache.popitem(last=False)  # Remove oldest
        self.evictions += 1
        log.debug(f"Cache EVICT: {evicted_key[0]} (size={self.max_size})")

    log.debug(f"Cache SET: {key}")

Functions

get_path_cache(max_size=100, ttl=300)

Get the global path cache instance (singleton).

Parameters:

Name Type Description Default
max_size int

Maximum cache size (only used on first call)

100
ttl int

Time-to-live in seconds (only used on first call)

300

Returns:

Type Description
PathCache

Global PathCache instance

Source code in core/path_cache.py
def get_path_cache(max_size: int = 100, ttl: int = 300) -> PathCache:
    """
    Get the global path cache instance (singleton).

    Args:
        max_size: Maximum cache size (only used on first call)
        ttl: Time-to-live in seconds (only used on first call)

    Returns:
        Global PathCache instance
    """
    global _global_path_cache
    if _global_path_cache is None:
        _global_path_cache = PathCache(max_size=max_size, ttl=ttl)
    return _global_path_cache

Vision Manager

core.vision_manager

Classes

VisionManager

Source code in core/vision_manager.py
class VisionManager:
    def __init__(self, client: Any, model: str, enabled: bool = True):
        self.client = client
        self.model = model
        self.enabled = enabled
        self.vision_client = None
        self.comfy_service: Optional[ComfyUIVisionService] = None

        # Determine provider
        self.vision_provider = os.getenv("VISION_PROVIDER", "DEFAULT").upper()

        # Mapping legacy config
        if self.vision_provider == "DEFAULT":
            # If using ZAI mode main client, we use it.
            # If main client is text-only, we might default to ZAI if keys present?
            # For now, DEFAULT implies "Use the main LLM client for vision"
            pass
        elif self.vision_provider not in ["ZAI", "ZAI_DIRECT", "COMFYUI", "DEFAULT"]:
            log.warning(
                f"Unknown VISION_PROVIDER '{self.vision_provider}', defaulting to DEFAULT"
            )
            self.vision_provider = "DEFAULT"

        self._initialize_client()

    def _initialize_client(self):
        """Initialize the appropriate vision client."""
        if not self.enabled:
            return

        if self.vision_provider == "COMFYUI":
            try:
                self.comfy_service = create_vision_service()
                log.info("👁️ ComfyUI Vision Service initialized")
            except Exception as e:
                log.warning(f"Failed to initialize ComfyUI Vision: {e}")
                self.comfy_service = None

        elif self.vision_provider in ["ZAI", "ZAI_DIRECT"]:
            # Dedicated ZAI Vision Client
            # If main client is NOT ZAI, we need to create a dedicated one here.
            # Or if self.client is None (e.g. text-only mode where we passed None? No, we pass client)

            vision_api_key = os.getenv("Z_AI_API_KEY")
            vision_base_url = os.getenv(
                "Z_AI_BASE_URL", "https://api.z.ai/api/coding/paas/v4"
            )

            # If we already have a client and it looks like a ZAI/OpenAI client, check if we can reuse it
            # But simpler to just create a new specific client if explicitly requested via VISION_PROVIDER

            if not vision_api_key:
                log.error(
                    "VISION_PROVIDER is ZAI but Z_AI_API_KEY is missing. Vision disabled."
                )
                return

            try:
                # Import OpenAI locally to avoid top-level dependency issues if not installed
                # (Though it should be installed in this env)
                from openai import OpenAI

                # Create dedicated client for vision
                dedicated_client = OpenAI(
                    api_key=vision_api_key,
                    base_url=vision_base_url,
                    timeout=30.0,  # Vision specific timeout
                )

                use_mcp = self.vision_provider == "ZAI"
                vision_model = os.getenv(
                    "VISION_MODEL", "glm-4.6v-flash"
                )  # Default vision model

                self.vision_client = create_zai_vision_client(
                    dedicated_client, vision_model, use_mcp=use_mcp
                )
                log.info(
                    f"Initialized dedicated Z.AI vision client (Model: {vision_model}, MCP: {use_mcp})"
                )
            except Exception as e:
                log.warning(f"Failed to initialize dedicated Z.AI vision client: {e}")
                self.vision_client = None

        elif self.vision_provider == "DEFAULT":
            # Use the main client passed in __init__
            # Only works if the main client supports the ZAI MCP/Fallback interface
            # effectively this is for backward compatibility or when using ZAI as main model
            if self.client:
                try:
                    # Check if we are in ZAI mode main?
                    # Actually we just try to create the wrapper.
                    # If the client is not ZAI compatible, this might fail or just not work well?
                    # create_zai_vision_client expects an OpenAI-compatible client.

                    # We assume if VISION_PROVIDER is DEFAULT, we only init vision_client
                    # if we are actually using ZAI or compatible?
                    # Or do we assume the user knows what they are doing?

                    # Current logic: existing code assumes self.client is valid for this.
                    # We only init if we can.
                    use_mcp = True  # Default behavior
                    self.vision_client = create_zai_vision_client(
                        self.client, self.model, use_mcp=use_mcp
                    )
                    log.info(
                        "Initialized default vision client (using main LLM connection)"
                    )
                except Exception as e:
                    log.info(
                        f"Default vision client init skipped or failed (expected for non-ZAI main models): {e}"
                    )
                    self.vision_client = None

    def ensure_mcp_alive(self):
        """Check if MCP server process matches expectations and restart if needed."""
        if not self.vision_client:
            return

        if (
            hasattr(self.vision_client, "mcp_process")
            and self.vision_client.mcp_process
        ):
            if self.vision_client.mcp_process.poll() is not None:
                log.warning(
                    f"MCP server process has terminated with code: {self.vision_client.mcp_process.returncode}"
                )
                log.warning("Attempting to restart MCP server...")
                try:
                    self.vision_client._start_mcp_server_sync()
                    if self.vision_client.is_connected:
                        log.info("MCP server restarted successfully")
                    else:
                        log.warning("Failed to restart MCP server")
                        self.vision_client.handle_vision_failure(
                            "MCP server process terminated and restart failed"
                        )
                except Exception as restart_error:
                    log.error(f"Failed to restart MCP server: {restart_error}")
                    self.vision_client.handle_vision_failure(
                        f"MCP server restart failed: {str(restart_error)}"
                    )

    async def analyze_image(self, image_path: str) -> Tuple[Optional[str], float]:
        """
        Analyze screenshot using configured vision provider.
        Returns (vision_json_str, time_taken_ms).
        Returns (None, 0) if analysis failed or disabled.
        """
        if not self.enabled or not image_path or not os.path.exists(image_path):
            return None, 0

        t_start = time.time()
        vision_result = None

        # --- COMFYUI PATH ---
        if self.vision_provider == "COMFYUI" and self.comfy_service:
            try:
                log.info("👁️ Analyzing image via ComfyUI (Moondream)...")
                vision_result = await self.comfy_service.analyze_image(image_path)
            except Exception as e:
                log.error(f"ComfyUI vision analysis failed: {e}")

        # --- ZAI PATH ---
        elif self.vision_provider in ["ZAI", "ZAI_DIRECT"] and self.vision_client:
            self.ensure_mcp_alive()
            try:
                log.info("Z.AI vision analyzing screenshot...")

                # Use async method if available
                if hasattr(self.vision_client, "analyze_image_async"):
                    # Fallback client has explicit async method
                    vision_result = await self.vision_client.analyze_image_async(
                        image_path, FACTUAL_PROMPT
                    )
                elif hasattr(self.vision_client, "analyze_image"):
                    # MCP client 'analyze_image' IS async
                    # But Fallback 'analyze_image' IS sync
                    # We need to distinguish or check if it's awaitable?
                    # Or just check class name? Or check signature?
                    # Safer: check for 'analyze_image_async' first (added to fallback).
                    # For MCP client: 'analyze_image' is async.

                    # We can try to await it?
                    # If it's the sync fallback, awaiting it (if not coroutine) might fail?
                    # No, we can't await a non-coroutine.

                    # Let's check if it's the MCP client or fallback.
                    is_fallback = "Fallback" in self.vision_client.__class__.__name__

                    if not is_fallback:
                        # MCP Client: analyze_image is async
                        vision_result = await self.vision_client.analyze_image(
                            image_path, FACTUAL_PROMPT
                        )
                    else:
                        # Fallback Client: analyze_image is sync, but we added analyze_image_async
                        # So we should have hit the first if block.
                        # Unless verify/reload didn't pick it up?
                        # It should work.
                        pass

            except Exception as e:
                log.error(f"Z.AI vision analysis failed: {e}")

        t_duration_ms = (time.time() - t_start) * 1000

        if not vision_result:
            return None, t_duration_ms

        # Post-processing
        processed = self._process_vision_result(vision_result)
        return processed, t_duration_ms

    def _process_vision_result(self, raw_result: str) -> str:
        """Clean up raw vision output: remove Japanese chars, extract JSON."""
        if not raw_result:
            return ""

        clean = raw_result
        try:
            # Remove Japanese characters
            clean = re.sub(r"[\u3040-\u309F\u30A0-\u30FF]", "", clean)

            # Extract JSON if present
            json_match = re.search(r"\{.*\}", clean, re.DOTALL)
            if json_match:
                # If we found JSON, use just that block + any text before it maybe?
                # Actually usually we want just the analysis.
                # But current usage expects the full text description sometimes?
                # The prompt asks for JSON output.
                pass

        except Exception:
            pass

        return clean

    def handle_vision_failure(self, error_message: str):
        """Handle critical vision failures via the MCP client."""
        if self.vision_client and hasattr(self.vision_client, "handle_vision_failure"):
            try:
                self.vision_client.handle_vision_failure(error_message)
            except Exception as e:
                log.error(f"Error handling vision failure: {e}")
Functions
analyze_image(image_path) async

Analyze screenshot using configured vision provider. Returns (vision_json_str, time_taken_ms). Returns (None, 0) if analysis failed or disabled.

Source code in core/vision_manager.py
async def analyze_image(self, image_path: str) -> Tuple[Optional[str], float]:
    """
    Analyze screenshot using configured vision provider.
    Returns (vision_json_str, time_taken_ms).
    Returns (None, 0) if analysis failed or disabled.
    """
    if not self.enabled or not image_path or not os.path.exists(image_path):
        return None, 0

    t_start = time.time()
    vision_result = None

    # --- COMFYUI PATH ---
    if self.vision_provider == "COMFYUI" and self.comfy_service:
        try:
            log.info("👁️ Analyzing image via ComfyUI (Moondream)...")
            vision_result = await self.comfy_service.analyze_image(image_path)
        except Exception as e:
            log.error(f"ComfyUI vision analysis failed: {e}")

    # --- ZAI PATH ---
    elif self.vision_provider in ["ZAI", "ZAI_DIRECT"] and self.vision_client:
        self.ensure_mcp_alive()
        try:
            log.info("Z.AI vision analyzing screenshot...")

            # Use async method if available
            if hasattr(self.vision_client, "analyze_image_async"):
                # Fallback client has explicit async method
                vision_result = await self.vision_client.analyze_image_async(
                    image_path, FACTUAL_PROMPT
                )
            elif hasattr(self.vision_client, "analyze_image"):
                # MCP client 'analyze_image' IS async
                # But Fallback 'analyze_image' IS sync
                # We need to distinguish or check if it's awaitable?
                # Or just check class name? Or check signature?
                # Safer: check for 'analyze_image_async' first (added to fallback).
                # For MCP client: 'analyze_image' is async.

                # We can try to await it?
                # If it's the sync fallback, awaiting it (if not coroutine) might fail?
                # No, we can't await a non-coroutine.

                # Let's check if it's the MCP client or fallback.
                is_fallback = "Fallback" in self.vision_client.__class__.__name__

                if not is_fallback:
                    # MCP Client: analyze_image is async
                    vision_result = await self.vision_client.analyze_image(
                        image_path, FACTUAL_PROMPT
                    )
                else:
                    # Fallback Client: analyze_image is sync, but we added analyze_image_async
                    # So we should have hit the first if block.
                    # Unless verify/reload didn't pick it up?
                    # It should work.
                    pass

        except Exception as e:
            log.error(f"Z.AI vision analysis failed: {e}")

    t_duration_ms = (time.time() - t_start) * 1000

    if not vision_result:
        return None, t_duration_ms

    # Post-processing
    processed = self._process_vision_result(vision_result)
    return processed, t_duration_ms
ensure_mcp_alive()

Check if MCP server process matches expectations and restart if needed.

Source code in core/vision_manager.py
def ensure_mcp_alive(self):
    """Check if MCP server process matches expectations and restart if needed."""
    if not self.vision_client:
        return

    if (
        hasattr(self.vision_client, "mcp_process")
        and self.vision_client.mcp_process
    ):
        if self.vision_client.mcp_process.poll() is not None:
            log.warning(
                f"MCP server process has terminated with code: {self.vision_client.mcp_process.returncode}"
            )
            log.warning("Attempting to restart MCP server...")
            try:
                self.vision_client._start_mcp_server_sync()
                if self.vision_client.is_connected:
                    log.info("MCP server restarted successfully")
                else:
                    log.warning("Failed to restart MCP server")
                    self.vision_client.handle_vision_failure(
                        "MCP server process terminated and restart failed"
                    )
            except Exception as restart_error:
                log.error(f"Failed to restart MCP server: {restart_error}")
                self.vision_client.handle_vision_failure(
                    f"MCP server restart failed: {str(restart_error)}"
                )
handle_vision_failure(error_message)

Handle critical vision failures via the MCP client.

Source code in core/vision_manager.py
def handle_vision_failure(self, error_message: str):
    """Handle critical vision failures via the MCP client."""
    if self.vision_client and hasattr(self.vision_client, "handle_vision_failure"):
        try:
            self.vision_client.handle_vision_failure(error_message)
        except Exception as e:
            log.error(f"Error handling vision failure: {e}")