from phBot import * import QtBind import os import struct import time import webbrowser from threading import Timer pName = 'FFortuneManager' pVersion = '1.2.7' DISCORD_URL = 'https://discord.gg/eB9sGSMYBg' OPCODE_FORTUNE_REQUEST = 0x7151 OPCODE_FORTUNE_RESPONSE = 0xB151 OPCODE_CHARACTER_DATA = 0x3013 COLOR_PRIMARY = '#5b57e0' COLOR_TEXT = '#2b3038' COLOR_MUTED = '#9aa0ac' COLOR_SUCCESS = '#1f9d63' COLOR_WARNING = '#c98a1a' COLOR_ERROR = '#d93a4d' FORTUNE_REQUEST_DELAY = 0.45 NEXT_ITEM_DELAY = 0.80 MAX_MAGIC_OPTIONS = 32 OFFSCREEN_X = 3000 is_running = False current_item_index = 0 selected_inventory = [] target_items = [] current_available_stats = [] current_targets = [] captured_items = {} capture_generation = 0 last_capture_time = 0.0 pending_timer_token = 0 pending_character_data = None pending_character_data_time = 0.0 pending_character_data_attempts = 0 single_roll_item = None # Media.pk2: refrnd_magicopt.txt, with the explicitly excluded options removed. WEAPON_STATS = ['STR', 'INT', 'Durability', 'Attack Rate', 'Evade Block', 'Critical'] SHIELD_STATS = ['STR', 'INT', 'Durability', 'Evade Critical', 'Block'] ARMOR_STATS = { 1: ['STR', 'INT', 'Durability', 'Parry Ratio', 'HP', 'MP'], 2: ['STR', 'INT', 'Durability', 'Parry Ratio'], 3: ['STR', 'INT', 'Durability', 'Parry Ratio', 'HP', 'MP', 'HP/MP Recovery'], 4: ['STR', 'INT', 'Durability', 'Parry Ratio', 'HP', 'MP'], 5: ['STR', 'INT', 'Durability', 'Parry Ratio'], 6: ['STR', 'INT', 'Durability', 'Parry Ratio'] } ACCESSORY_BASE_STATS = [ 'STR', 'INT', 'Frostbite Resist', 'Fire Resist', 'Lightning Resist', 'Poison Resist', 'Zombie Resist' ] ACCESSORY_STATS = { 1: ACCESSORY_BASE_STATS + ['CSMP Resist', 'Sleep Resist'], 2: ACCESSORY_BASE_STATS + ['Stun Resist'], 3: ACCESSORY_BASE_STATS + ['Disease Resist', 'Fear Resist'] } WEAPON_NAMES = { 2: 'Sword', 3: 'Blade', 4: 'Spear', 5: 'Glaive', 6: 'Bow', 7: 'One-handed Sword', 8: 'Two-handed Sword', 9: 'Axe', 10: 'Warlock Rod', 11: 'Staff', 12: 'Crossbow', 13: 'Dagger', 14: 'Harp', 15: 'Cleric Rod' } ARMOR_NAMES = {1: 'Head', 2: 'Shoulder', 3: 'Chest', 4: 'Legs', 5: 'Hands', 6: 'Foot'} ACCESSORY_NAMES = {1: 'Earring', 2: 'Necklace', 3: 'Ring'} # Magic option signatures exported from this server's magicoption_all.txt. SIGNATURE_NAMES = { 7566450: 'STR', 6909556: 'INT', 1685418613: 'Durability', 26738: 'Attack Rate', 1702257260: 'Evade Block', 6517353: 'Critical', 6451817: 'Block', 26736: 'HP', 28016: 'MP', 1752198512: 'HP/MP Recovery', 1919120233: 'CSMP Resist', 1920167017: 'Sleep Resist', 1920169065: 'Stun Resist', 1919183209: 'Disease Resist', 1919313257: 'Fear Resist', # Known but intentionally excluded from target selection. 1635018849: 'Athanasia', 1936682089: 'Solid', 1819632491: 'Luck', 7497072: 'Repair', 25970: 'Parry Ratio', 1702257522: 'Evade Critical', 26234: 'Frostbite Resist', 25205: 'Fire Resist', 25971: 'Lightning Resist', 28787: 'Poison Resist', 31330: 'Zombie Resist' } # Fortune response codes observed by the original plugin/server implementation. CLASSIC_CODES = { # Armor/accessory family used by this server. 0x0A: 'STR', 0x0B: 'INT', 0x0C: 'Durability', 0x0F: 'Evade Critical', 0x10: 'Parry Ratio', 0x11: 'HP', 0x12: 'MP', 0x13: 'Frostbite Resist', 0x14: 'Lightning Resist', 0x15: 'Fire Resist', 0x16: 'Poison Resist', 0x17: 'Zombie Resist', 0x19: 'Block', 0x1A: 'Stun Resist', 0x1C: 'CSMP Resist', 0x1D: 'Disease Resist', 0x1E: 'Sleep Resist', 0x1F: 'Fear Resist', # Weapon/classic family retained from the original server plugin. 0x39: 'STR', 0x53: 'STR', 0x54: 'STR', 0x55: 'STR', 0x3A: 'INT', 0x56: 'INT', 0x57: 'INT', 0x58: 'INT', 0x3B: 'Durability', 0x59: 'Durability', 0x5A: 'Durability', 0x47: 'Critical', 0x7D: 'Critical', 0x7E: 'Critical', 0x3C: 'Attack Rate', 0x5C: 'Attack Rate', 0x3D: 'Block Rate', 0x5F: 'Block Rate', 0x40: 'HP', 0x68: 'HP', 0x41: 'MP', 0x6B: 'MP', 0x49: 'Stun', 0x4A: 'HP/MP Recovery', 0x4B: 'Combustion', 0x4C: 'Disease', 0x4D: 'Sleep', 0x4E: 'Fear' } WEAPON_CONTEXT_CODES = { 0x69: 'STR', 0x6A: 'INT', 0x6B: 'Durability', 0x6C: 'Attack Rate', 0x6D: 'Evade Block', 0x77: 'Critical' } IGNORED_RESPONSE_STATS = { 'Athanasia', 'Solid', 'Luck', 'Repair' } DG12_CODES = { 0x0049: 'STR', 0x004F: 'INT', 0x0067: 'Attack Rate', 0x0073: 'Evade Block', 0x008B: 'HP', 0x0097: 'MP', 0x010C: 'Critical' } def plugin_log(message): log('[%s] %s' % (pName, str(message))) def fixed_width_text(content, width): return ( '' '
{1}
' ).format(width, content) def html_safe(value): return str(value).replace('&', '&').replace('<', '<').replace('>', '>') def set_status(message, color=COLOR_MUTED): QtBind.setText( gui, lbl_status, fixed_width_text('%s' % (color, html_safe(message)), 460) ) def set_capture_status(message, color=COLOR_MUTED): QtBind.setText( gui, lbl_capture, fixed_width_text('%s' % (color, html_safe(message)), 310) ) def discord_clicked(): try: webbrowser.open(DISCORD_URL) set_status('Opening Discord invite...', COLOR_SUCCESS) except Exception as error: plugin_log('Discord link error: %s' % error) set_status('Could not open Discord invite', COLOR_ERROR) def item_classification(item): try: data = get_item(int(item.get('model', 0))) except Exception: data = None if not data or int(data.get('tid1', 0)) != 1: return None tid2 = int(data.get('tid2', 0)) tid3 = int(data.get('tid3', 0)) if tid2 == 6 and tid3 in WEAPON_NAMES: return ('Weapon', WEAPON_NAMES[tid3], list(WEAPON_STATS)) if tid2 == 4 and tid3 in (1, 2): return ('Shield', 'Chinese Shield' if tid3 == 1 else 'European Shield', list(SHIELD_STATS)) if tid2 in (1, 2, 3, 9, 10, 11) and tid3 in ARMOR_STATS: family = {1: 'Garment', 2: 'Protector', 3: 'Armor', 9: 'Robe', 10: 'Light Armor', 11: 'Heavy Armor'}[tid2] return ('Armor', '%s %s' % (family, ARMOR_NAMES[tid3]), list(ARMOR_STATS[tid3])) if tid2 in (5, 12) and tid3 in ACCESSORY_STATS: race = 'Chinese' if tid2 == 5 else 'European' return ('Accessory', '%s %s' % (race, ACCESSORY_NAMES[tid3]), list(ACCESSORY_STATS[tid3])) return None def inventory_snapshot(): inventory = get_inventory() if not inventory or 'items' not in inventory: return [] result = [] for slot, item in enumerate(inventory['items']): if slot < 13 or not item: continue classification = item_classification(item) if not classification: continue copy_item = dict(item) copy_item['slot'] = slot copy_item['category'] = classification[0] copy_item['subtype'] = classification[1] copy_item['available_stats'] = classification[2] result.append(copy_item) return result def get_fortune_slot(): inventory = get_inventory() if not inventory or 'items' not in inventory: return None for slot, item in enumerate(inventory['items']): if item and 'Wheel of Fortune' in str(item.get('name', '')): return slot return None def find_inventory_item(slot): inventory = get_inventory() if not inventory or 'items' not in inventory: return None items = inventory['items'] if slot < 0 or slot >= len(items): return None return items[slot] def format_inventory_row(item): captured = captured_items.get(item['slot']) marker = '+' if captured and captured.get('model') == item.get('model') else '?' return '%s Slot %d | +%s | %s | %s' % ( marker, item['slot'], item.get('plus', 0), item['subtype'], item.get('name', 'Unknown')) def refresh_inventory(): global selected_inventory selected_inventory = inventory_snapshot() QtBind.clear(gui, lst_inventory) for item in selected_inventory: QtBind.append(gui, lst_inventory, format_inventory_row(item)) set_status('Found %d eligible inventory item(s)' % len(selected_inventory), COLOR_SUCCESS) def selected_inventory_item(): text = QtBind.text(gui, lst_inventory) if not text: return None try: slot = int(text.split('Slot ', 1)[1].split(' ', 1)[0]) except Exception: return None for item in selected_inventory: if item['slot'] == slot: return item return None def format_raw_value(value): return '%d (0x%08X)' % (value, value) def show_selected_item(): global current_available_stats, current_targets item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return current_available_stats = list(item['available_stats']) current_targets = [] QtBind.clear(gui, lst_available_stats) QtBind.clear(gui, lst_targets) for name in current_available_stats: QtBind.append(gui, lst_available_stats, name) details = '%s | Slot %d | %s | Lv %s | +%s | Dur %s' % ( item.get('name', 'Unknown'), item['slot'], item['subtype'], get_item(item['model']).get('level', '?'), item.get('plus', 0), item.get('durability', '?')) QtBind.setText(gui, lbl_selected_item, fixed_width_text( '%s' % (COLOR_TEXT, html_safe(details)), 350)) captured = captured_items.get(item['slot']) if not captured or captured.get('model') != item.get('model'): set_capture_status('Current stats not observed yet; use Test One Roll', COLOR_WARNING) return options = captured.get('options', []) set_capture_status( 'Verified from %s: %d option(s)' % (captured.get('source', 'packet'), len(options)), COLOR_SUCCESS) def add_target(): stat = QtBind.text(gui, lst_available_stats) if not stat: set_status('Select an available stat', COLOR_WARNING) return try: count = int(QtBind.text(gui, txt_target_count)) except Exception: count = 0 if count <= 0 or count > 32: set_status('Target line count must be between 1 and 32', COLOR_ERROR) return for target in current_targets: if target['name'] == stat: target['count'] = count refresh_targets() set_status('Updated target: %s x%d' % (stat, count), COLOR_SUCCESS) return current_targets.append({'name': stat, 'count': count}) refresh_targets() set_status('Added target: %s x%d' % (stat, count), COLOR_SUCCESS) def remove_target(): text = QtBind.text(gui, lst_targets) if not text: set_status('Select a target to remove', COLOR_WARNING) return name = text.split(' x', 1)[0] current_targets[:] = [target for target in current_targets if target['name'] != name] refresh_targets() set_status('Removed target: %s' % name, COLOR_SUCCESS) def refresh_targets(): QtBind.clear(gui, lst_targets) for target in current_targets: QtBind.append(gui, lst_targets, '%s x%d' % (target['name'], target['count'])) def queue_selected_item(): item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return if not current_targets: set_status('Add at least one stat target', COLOR_WARNING) return for queued in target_items: if queued['slot'] == item['slot']: set_status('This inventory slot is already queued', COLOR_WARNING) return target_items.append({ 'slot': item['slot'], 'model': item['model'], 'name': item.get('name', 'Unknown'), 'subtype': item['subtype'], 'targets': [dict(target) for target in current_targets] }) refresh_queue() set_status('Queued %s' % item.get('name', 'Unknown'), COLOR_SUCCESS) def refresh_queue(): QtBind.clear(gui, lst_queue) for index, item in enumerate(target_items): marker = '>' if is_running and index == current_item_index else '-' targets = ', '.join('%s x%d' % (x['name'], x['count']) for x in item['targets']) QtBind.append(gui, lst_queue, '%s %d) Slot %d | %s | %s' % ( marker, index + 1, item['slot'], item['name'], targets)) def remove_queued_item(): if is_running: set_status('Stop automation before changing the queue', COLOR_WARNING) return text = QtBind.text(gui, lst_queue) if not text: set_status('Select a queued item', COLOR_WARNING) return try: index = int(text.split(')', 1)[0].replace('-', '').replace('>', '').strip()) - 1 except Exception: set_status('Could not read queued item', COLOR_ERROR) return if 0 <= index < len(target_items): removed = target_items.pop(index) refresh_queue() set_status('Removed %s' % removed['name'], COLOR_SUCCESS) def clear_queue(): if is_running: set_status('Stop automation before clearing the queue', COLOR_WARNING) return target_items[:] = [] refresh_queue() set_status('Queue cleared', COLOR_SUCCESS) def active_item(): if 0 <= current_item_index < len(target_items): return target_items[current_item_index] return None def invalidate_timer(): global pending_timer_token pending_timer_token += 1 return pending_timer_token def schedule_request(delay): token = pending_timer_token Timer(delay, scheduled_request, args=[token]).start() def scheduled_request(token): if token == pending_timer_token: send_fortune_request() def send_fortune_request(): if not is_running: return item = active_item() if not item: stop_automation('All queued items completed', COLOR_SUCCESS) return live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: stop_automation('Item moved or changed at slot %d' % item['slot'], COLOR_ERROR) return fortune_slot = get_fortune_slot() if fortune_slot is None: stop_automation('Wheel of Fortune not found', COLOR_ERROR) return if item['slot'] > 255 or fortune_slot > 255: stop_automation('Inventory slot is outside packet range', COLOR_ERROR) return packet = b'\x02\x19\x02' + bytes([item['slot'], fortune_slot]) inject_joymax(OPCODE_FORTUNE_REQUEST, packet, False) set_status('Rolling %s...' % item['name'], COLOR_WARNING) def test_one_roll(): global single_roll_item if is_running: set_status('Stop automation before running a one-roll test', COLOR_WARNING) return if single_roll_item is not None: set_status('A one-roll test is already waiting for a response', COLOR_WARNING) return item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: set_status('Selected item moved or changed', COLOR_ERROR) return fortune_slot = get_fortune_slot() if fortune_slot is None: set_status('Wheel of Fortune not found', COLOR_ERROR) return if item['slot'] > 255 or fortune_slot > 255: set_status('Inventory slot is outside packet range', COLOR_ERROR) return single_roll_item = { 'slot': item['slot'], 'model': item['model'], 'name': item.get('name', 'Unknown') } packet = b'\x02\x19\x02' + bytes([item['slot'], fortune_slot]) inject_joymax(OPCODE_FORTUNE_REQUEST, packet, False) set_status('One-roll test sent for %s; waiting for 0xB151...' % ( single_roll_item['name']), COLOR_WARNING) def start_automation(): global is_running, current_item_index if is_running: set_status('Automation is already running', COLOR_WARNING) return if not target_items: set_status('Queue at least one item', COLOR_WARNING) return for item in target_items: live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: set_status('Queued item is no longer at slot %d' % item['slot'], COLOR_ERROR) return if get_fortune_slot() is None: set_status('Wheel of Fortune not found', COLOR_ERROR) return current_item_index = 0 is_running = True invalidate_timer() refresh_queue() set_status('Automation started', COLOR_SUCCESS) send_fortune_request() def stop_automation(message='Automation stopped', color=COLOR_WARNING): global is_running is_running = False invalidate_timer() refresh_queue() set_status(message, color) def decode_fortune_group(group): if len(group) != 8: return None full_code = struct.unpack_from('= 35: options = [] blue_section = data[35:] for index in range(len(blue_section) // 8): group = list(blue_section[index * 8:(index + 1) * 8]) full_code = struct.unpack_from('= target['count'] for target in targets) def response_matches_item(item, options): live_item = find_inventory_item(item['slot']) classification = item_classification(live_item) if live_item else None if not classification: return False, 'Selected item classification is unavailable' available = classification[2] invalid_names = [] for option in options: name = option['name'] if name in available: continue if name in IGNORED_RESPONSE_STATS: continue if name == 'Block Rate' and 'Evade Block' in available: continue if name == 'Block Rate' and 'Block' in available: continue if name == 'Evade Block' and 'Block Rate' in available: continue invalid_names.append(name) invalid = sorted(set(invalid_names)) if invalid: return False, 'Impossible stat(s) for %s: %s' % ( classification[1], ', '.join(invalid)) return True, '' def log_fortune_raw(label, data): plugin_log('%s 0xB151 raw (%d bytes): %s%s' % ( label, len(data), ' '.join('%02X' % value for value in data[:512]), ' ...' if len(data) > 512 else '')) def cache_and_show_response(item, options, source): live_item = find_inventory_item(item['slot']) classification = item_classification(live_item) if live_item else None available = classification[2] if classification else [] # This server reuses the classic block-related response code. Resolve it # against the selected item's Media target group. for option in options: if option['name'] == 'Block Rate' and 'Evade Block' in available: option['name'] = 'Evade Block' elif option['name'] == 'Block Rate' and 'Block' in available: option['name'] = 'Block' elif option['name'] == 'Evade Block' and 'Block Rate' in available: option['name'] = 'Block Rate' captured_items[item['slot']] = { 'model': item['model'], 'options': options, 'source': source, 'captured_at': time.time() } counts = {} for option in options: counts[option['name']] = counts.get(option['name'], 0) + 1 set_capture_status('Verified from %s: %d option(s)' % ( source, len(options)), COLOR_SUCCESS) plugin_log('Slot %d result: %s' % ( item['slot'], ', '.join('%s x%d' % pair for pair in counts.items()))) return counts def process_single_roll_response(data): global single_roll_item item = single_roll_item single_roll_item = None if not item: return log_fortune_raw('One-roll', data) options = parse_fortune_response(data, item) if not options: set_status('One-roll response received, but no known stats were decoded', COLOR_ERROR) set_capture_status('0xB151 received; stat format needs diagnostics', COLOR_ERROR) plugin_log('One-roll 0xB151 decode failed: %d bytes' % len(data)) return valid, reason = response_matches_item(item, options) if not valid: set_status('One-roll response rejected: %s' % reason, COLOR_ERROR) set_capture_status('Unsafe 0xB151 layout; raw response logged', COLOR_ERROR) plugin_log('One-roll response rejected: %s' % reason) return cache_and_show_response(item, options, '0xB151 one-roll test') set_status('One-roll test completed; no repeat was scheduled', COLOR_SUCCESS) def process_fortune_response(data): global current_item_index item = active_item() if not item: return options = parse_fortune_response(data, item) if not options: log_fortune_raw('Automation decode failure', data) stop_automation('Response had no verified stats; automation stopped', COLOR_ERROR) return valid, reason = response_matches_item(item, options) if not valid: log_fortune_raw('Automation unsafe layout', data) plugin_log('Automation response rejected: %s' % reason) stop_automation('%s; automation stopped' % reason, COLOR_ERROR) return cache_and_show_response(item, options, '0xB151 Fortune response') if targets_met(options, item['targets']): set_status('Target reached for %s' % item['name'], COLOR_SUCCESS) current_item_index += 1 refresh_queue() if current_item_index >= len(target_items): stop_automation('All targets reached', COLOR_SUCCESS) else: schedule_request(NEXT_ITEM_DELAY) else: schedule_request(FORTUNE_REQUEST_DELAY) def option_name_from_signature(signature): return SIGNATURE_NAMES.get(signature, 'Unknown 0x%08X' % signature) def parse_equip_payload(data, item_id_position): # vSRO equip record after RefObjID: plus(1), variance(8), durability(4), optionCount(1). base = item_id_position + 4 if base + 14 > len(data): return None plus = data[base] if plus > 100: return None option_count_position = base + 13 option_count = data[option_count_position] if option_count > MAX_MAGIC_OPTIONS: return None cursor = option_count_position + 1 options = [] for unused in range(option_count): if cursor + 9 > len(data): return None signature = struct.unpack_from('= 5 and data[position - 5] == slot and data[position - 4:position] == b'\x00\x00\x00\x00': anchors.append('slot+rent') if position >= 9 and data[position - 9] == slot: anchors.append('slot+extended') if not anchors: continue parsed = parse_equip_payload(data, position) if parsed: parsed['item_id_position'] = position parsed['anchor'] = anchors[0] candidates.append(parsed) if len(candidates) == 1: return candidates[0] return None def hex_window(data, position, before=16, after=36): start = max(0, position - before) end = min(len(data), position + after) return '%d..%d: %s' % ( start, end, ' '.join('%02X' % value for value in data[start:end])) def log_capture_diagnostics(data, items): plugin_log('0x3013 diagnostics: packet=%d bytes, eligible inventory=%d' % ( len(data), len(items))) if not items: plugin_log('0x3013 diagnostics: get_inventory returned no eligible bag items') return for item in items[:12]: model = int(item.get('model', 0)) needle = struct.pack(' 5.0: set_capture_status('Teleport completed; waiting for verified 0x3013 data...', COLOR_WARNING) def disconnected(): global pending_character_data, pending_character_data_attempts global single_roll_item if is_running: stop_automation('Disconnected; automation stopped', COLOR_ERROR) captured_items.clear() pending_character_data = None pending_character_data_attempts = 0 single_roll_item = None set_capture_status('Disconnected; inventory capture cleared', COLOR_MUTED) def event_loop(): global pending_character_data, pending_character_data_attempts # handle_joymax can run before phBot publishes its refreshed get_inventory() # snapshot. Retry the last character-data packet briefly from the normal loop. if pending_character_data is None: return if time.time() - pending_character_data_time < 0.20: return data = pending_character_data pending_character_data_attempts += 1 before_generation = capture_generation final_attempt = pending_character_data_attempts >= 6 capture_character_inventory(data, quiet=not final_attempt, diagnostics=final_attempt) if capture_generation != before_generation or pending_character_data_attempts >= 6: pending_character_data = None pending_character_data_attempts = 0 def handle_joymax(opcode, data): global pending_character_data, pending_character_data_time global pending_character_data_attempts try: if opcode == OPCODE_CHARACTER_DATA: pending_character_data = bytes(data) pending_character_data_time = time.time() pending_character_data_attempts = 0 set_capture_status('0x3013 received; verifying inventory records...', COLOR_WARNING) elif opcode == OPCODE_FORTUNE_RESPONSE: if single_roll_item is not None: process_single_roll_response(data) elif is_running: process_fortune_response(data) except Exception as error: plugin_log('Packet processing error for 0x%04X: %s' % (opcode, error)) if opcode == OPCODE_CHARACTER_DATA: set_capture_status('0x3013 parse error; see phBot log', COLOR_ERROR) return True def move_page(widgets, visible): for widget, x, y in widgets: QtBind.move(gui, widget, x if visible else OFFSCREEN_X, y) def show_queue_page(): move_page(setup_page_widgets, False) move_page(queue_page_widgets, True) QtBind.move(gui, btn_show_queue, OFFSCREEN_X, 6) QtBind.move(gui, btn_show_setup, 345, 6) refresh_queue() set_status('Queue controls ready', COLOR_MUTED) def show_setup_page(): move_page(queue_page_widgets, False) move_page(setup_page_widgets, True) QtBind.move(gui, btn_show_setup, OFFSCREEN_X, 6) QtBind.move(gui, btn_show_queue, 345, 6) # GUI gui = QtBind.init(__name__, pName) QtBind.createLabel(gui, '✦ %s' % ( COLOR_PRIMARY, pName), 12, 6) QtBind.createLabel(gui, 'v%s' % (COLOR_MUTED, pVersion), 205, 12) btn_show_queue = QtBind.createButton(gui, 'show_queue_page', 'Queue & Start →', 345, 6) btn_show_setup = QtBind.createButton(gui, 'show_setup_page', '← Item Setup', OFFSCREEN_X, 6) QtBind.createButton(gui, 'discord_clicked', u'\U0001f4ac Discord', 462, 6) QtBind.createLabel(gui, u'⚜ Made By FascinaTe' % COLOR_PRIMARY, 565, 11) QtBind.createLineEdit(gui, '', 12, 30, 716, 1) setup_page_widgets = [] queue_page_widgets = [] lbl_inventory_header = QtBind.createLabel(gui, '1. ELIGIBLE INVENTORY' % COLOR_PRIMARY, 12, 42) btn_refresh = QtBind.createButton(gui, 'refresh_inventory', '↻ Refresh', 12, 62) btn_inspect = QtBind.createButton(gui, 'show_selected_item', 'Inspect Selected', 105, 62) btn_test_roll = QtBind.createButton(gui, 'test_one_roll', 'Test One Roll', 225, 62) lst_inventory = QtBind.createList(gui, 12, 88, 360, 160) lbl_selected_header = QtBind.createLabel(gui, 'SELECTED ITEM' % COLOR_PRIMARY, 12, 260) lbl_selected_item = QtBind.createLabel(gui, fixed_width_text( 'No item selected' % COLOR_MUTED, 350), 12, 282) line_vertical = QtBind.createLineEdit(gui, '', 385, 42, 1, 264) lbl_stats_header = QtBind.createLabel(gui, '2. AVAILABLE STATS' % COLOR_PRIMARY, 400, 42) lst_available_stats = QtBind.createList(gui, 400, 65, 140, 180) lbl_targets_header = QtBind.createLabel(gui, 'ITEM TARGETS' % COLOR_PRIMARY, 555, 42) lst_targets = QtBind.createList(gui, 555, 65, 160, 145) btn_remove_target = QtBind.createButton(gui, 'remove_target', 'Remove Target', 555, 216) btn_queue_item = QtBind.createButton(gui, 'queue_selected_item', 'Add Item to Queue', 575, 248) lbl_required = QtBind.createLabel(gui, 'Required lines', 400, 258) txt_target_count = QtBind.createLineEdit(gui, '1', 400, 279, 45, 20) btn_add_target = QtBind.createButton(gui, 'add_target', 'Add / Update', 455, 275) setup_page_widgets.extend([ (lbl_inventory_header, 12, 42), (btn_refresh, 12, 62), (btn_inspect, 105, 62), (btn_test_roll, 225, 62), (lst_inventory, 12, 88), (lbl_selected_header, 12, 260), (lbl_selected_item, 12, 282), (line_vertical, 385, 42), (lbl_stats_header, 400, 42), (lst_available_stats, 400, 65), (lbl_targets_header, 555, 42), (lst_targets, 555, 65), (btn_remove_target, 555, 216), (btn_queue_item, 575, 248), (lbl_required, 400, 258), (txt_target_count, 400, 279), (btn_add_target, 455, 275) ]) lbl_queue_header = QtBind.createLabel(gui, '4. AUTOMATION QUEUE' % COLOR_PRIMARY, OFFSCREEN_X, 48) lbl_queue_help = QtBind.createLabel(gui, fixed_width_text('Review every item and target before starting.' % COLOR_MUTED, 500), OFFSCREEN_X, 70) lst_queue = QtBind.createList(gui, OFFSCREEN_X, 92, 535, 120) btn_remove_queue = QtBind.createButton(gui, 'remove_queued_item', 'Remove Item', OFFSCREEN_X, 92) btn_clear_queue = QtBind.createButton(gui, 'clear_queue', 'Clear Queue', OFFSCREEN_X, 124) btn_start = QtBind.createButton(gui, 'start_automation', '▶ START', OFFSCREEN_X, 228) btn_stop = QtBind.createButton(gui, 'stop_automation', '■ STOP', OFFSCREEN_X, 228) lbl_capture = QtBind.createLabel(gui, fixed_width_text('Waiting for a verified Fortune response...' % COLOR_WARNING, 480), OFFSCREEN_X, 262) lbl_status = QtBind.createLabel(gui, fixed_width_text('Ready' % COLOR_MUTED, 680), OFFSCREEN_X, 292) queue_page_widgets.extend([ (lbl_queue_header, 12, 48), (lbl_queue_help, 12, 70), (lst_queue, 12, 92), (btn_remove_queue, 560, 92), (btn_clear_queue, 560, 124), (btn_start, 12, 228), (btn_stop, 115, 228), (lbl_capture, 225, 232), (lbl_status, 12, 272) ]) refresh_inventory() set_capture_status('Current stats appear after the first verified Fortune response', COLOR_WARNING) log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)