plugin iSRO-R v1.3

Wheel of Fate

by @CK3 · 2d ago · updated 6h ago · 12 views

WheelOfFate - plugin for phBot (iSRO-R)

  • Automatically uses "Wheel of Fate" on an item and stops when the item has a number of magic (blue) lines >= the value you choose. The VALUES of the bluelines do not matter, only HOW MANY lines there are.

Changelog

v1.3 latest 2026-08-02
  • Sound when condition met on item.
v1.2 files only

Released with files, no release notes written yet.

WheelOfFate.py v1.3 python · 449 lines · 13,447 B · 6h ago Download
# -*- coding: utf-8 -*-
#
# WheelOfFate - plugin for phBot  (iSRO-R)
# ----------------------------------------
# Automatically uses "Wheel of Fate" on an item and stops when the item has a
# number of magic (blue) lines >= the value you choose. The VALUES of the blue
# lines do not matter, only HOW MANY lines there are.
#
# Separate from the original AutoWheels. No database (db3) needed.
#
# NOTE: every spin consumes one Wheel of Fate. The first spin on an item always
# happens (the plugin cannot read the current lines without spinning once).
#
from phBot import *
from threading import Timer, Thread
import struct
import QtBind
import os
import json

try:
	import winsound
except Exception:
	winsound = None

pName = 'WheelOfFate'
pVersion = '1.3'

# wheel use packet (same as AutoWheels): 0x7151
WHEEL_OPCODE = 0x7151
WHEEL_RESULT_OPCODE = 0xB151
# offset of number_of_magiclines inside the 0xB151 response
MAGICLINES_OFFSET = 26

CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), pName + '.json')

started = False
_timer = None
current_items = {}   # combo index -> inventory slot
attempts = 0
queue = []           # (slot, label) items waiting
history = []         # result lines, newest first

# ============================================================================
# GUI
# ============================================================================
gui = QtBind.init(__name__, pName)

# ---- Title ----
QtBind.createLabel(gui, 'W H E E L   O F   F A T E        v%s' % pVersion, 10, 6)

# ---- Item selection ----
QtBind.createLabel(gui, 'Item to spin:', 10, 30)
combo_items = QtBind.createCombobox(gui, 10, 48, 240, 22)
QtBind.createButton(gui, 'on_refresh', '  Refresh items  ', 258, 47)
QtBind.createButton(gui, 'on_add_queue', '  + Queue  ', 258, 74)
QtBind.createButton(gui, 'on_clear_queue', '  Clear queue  ', 360, 74)

# ---- Queue (right, under Clear queue) ----
QtBind.createLabel(gui, 'Queue (remaining):', 360, 104)
lstQueue = QtBind.createList(gui, 360, 124, 210, 176)

# ---- Settings ----
QtBind.createLabel(gui, 'Stop at blue lines >=:', 10, 102)
txtLines = QtBind.createLineEdit(gui, '4', 210, 100, 40, 20)

QtBind.createLabel(gui, 'Delay between wheels (ms):', 10, 126)
txtDelay = QtBind.createLineEdit(gui, '2000', 210, 124, 50, 20)
QtBind.createButton(gui, 'on_save', '  Save  ', 280, 123)

btnStartStop = QtBind.createButton(gui, 'on_startstop', '      Start      ', 10, 150)
lblStatus = QtBind.createLabel(gui, 'Status: stopped', 150, 154)

# ---- History (left) : newest line on top, scroll for the older ones ----
QtBind.createLabel(gui, 'History (attempt: lines):', 10, 182)
lstResult = QtBind.createList(gui, 10, 200, 240, 44)

# ---- Sound alert ----
chkSound = QtBind.createCheckBox(gui, 'on_dummy', 'Sound when target reached', 10, 248)
QtBind.createLabel(gui, 'WAV (blank = beep):', 10, 272)
txtWav = QtBind.createLineEdit(gui, '', 130, 270, 215, 20)
QtBind.createButton(gui, 'on_browse_wav', ' Browse ', 130, 296)
QtBind.createButton(gui, 'on_test_sound', ' Test ', 235, 296)

# ---- Footer ----
QtBind.createLabel(gui, 'Author : CK3 @ iLog1K.net', 10, 330)


def on_dummy(*_):
	pass


def set_status(txt):
	QtBind.setText(gui, lblStatus, 'Status: ' + txt)


def play_alert():
	"""Beep / play a WAV when an item reaches the target. Never blocks the bot."""
	try:
		if not QtBind.isChecked(gui, chkSound):
			return
		path = QtBind.text(gui, txtWav).strip().strip('"')
	except Exception:
		return
	if winsound is None:
		return

	def _run():
		try:
			if path and os.path.isfile(path):
				winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
			else:
				for freq, dur in ((880, 150), (1175, 150), (1568, 350)):
					winsound.Beep(freq, dur)
		except Exception as e:
			log('[%s] Sound error: %s' % (pName, e))

	Thread(target=_run, daemon=True).start()


def on_test_sound(*_):
	try:
		if not QtBind.isChecked(gui, chkSound):
			log('[%s] Tick "Sound when target reached" first.' % pName)
			return
	except Exception:
		pass
	play_alert()


def _ask_wav_file():
	"""Native Windows "Open file" dialog - no extra dependencies."""
	try:
		import ctypes
		from ctypes import wintypes
	except Exception:
		return None

	class OPENFILENAMEW(ctypes.Structure):
		_fields_ = [
			('lStructSize', wintypes.DWORD),
			('hwndOwner', wintypes.HWND),
			('hInstance', wintypes.HINSTANCE),
			('lpstrFilter', wintypes.LPCWSTR),
			('lpstrCustomFilter', wintypes.LPWSTR),
			('nMaxCustFilter', wintypes.DWORD),
			('nFilterIndex', wintypes.DWORD),
			('lpstrFile', wintypes.LPWSTR),
			('nMaxFile', wintypes.DWORD),
			('lpstrFileTitle', wintypes.LPWSTR),
			('nMaxFileTitle', wintypes.DWORD),
			('lpstrInitialDir', wintypes.LPCWSTR),
			('lpstrTitle', wintypes.LPCWSTR),
			('Flags', wintypes.DWORD),
			('nFileOffset', wintypes.WORD),
			('nFileExtension', wintypes.WORD),
			('lpstrDefExt', wintypes.LPCWSTR),
			('lCustData', wintypes.LPARAM),
			('lpfnHook', ctypes.c_void_p),
			('lpTemplateName', wintypes.LPCWSTR),
			('pvReserved', ctypes.c_void_p),
			('dwReserved', wintypes.DWORD),
			('FlagsEx', wintypes.DWORD),
		]

	cur = ''
	try:
		cur = QtBind.text(gui, txtWav).strip().strip('"')
	except Exception:
		pass
	buf = ctypes.create_unicode_buffer(1024)
	if cur:
		buf.value = cur

	ofn = OPENFILENAMEW()
	ofn.lStructSize = ctypes.sizeof(OPENFILENAMEW)
	ofn.lpstrFilter = 'WAV files\0*.wav\0All files\0*.*\0\0'
	ofn.lpstrFile = ctypes.cast(buf, wintypes.LPWSTR)
	ofn.nMaxFile = 1024
	ofn.lpstrTitle = 'Choose the alert sound (.wav)'
	ofn.lpstrInitialDir = (os.path.dirname(cur) if cur else
						   os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Media'))
	# NOCHANGEDIR | PATHMUSTEXIST | FILEMUSTEXIST | EXPLORER
	ofn.Flags = 0x00000008 | 0x00000800 | 0x00001000 | 0x00080000
	try:
		ok = ctypes.windll.comdlg32.GetOpenFileNameW(ctypes.byref(ofn))
	except Exception as e:
		log('[%s] File dialog error: %s' % (pName, e))
		return None
	return buf.value if ok else None


def on_browse_wav(*_):
	path = _ask_wav_file()
	if not path:
		return
	QtBind.setText(gui, txtWav, path)
	log('[%s] Sound file: %s' % (pName, path))
	on_save()


def add_history(text):
	"""Newest result on the first row; older ones below (scroll to see them)."""
	history.insert(0, text)
	try:
		QtBind.clear(gui, lstResult)
		for line in history:
			QtBind.append(gui, lstResult, line)
	except Exception:
		pass


def clear_history():
	history[:] = []
	try:
		QtBind.clear(gui, lstResult)
	except Exception:
		pass


def lines_target():
	try:
		return max(1, int(QtBind.text(gui, txtLines).strip()))
	except Exception:
		return 4


def delay_sec():
	try:
		return max(0.0, int(QtBind.text(gui, txtDelay).strip()) / 1000.0)
	except Exception:
		return 2.0


# ============================================================================
# Config
# ============================================================================
def on_save(*_):
	try:
		with open(CONFIG_FILE, 'w') as f:
			json.dump({
				'lines': lines_target(),
				'delay': int(delay_sec() * 1000),
				'sound': bool(QtBind.isChecked(gui, chkSound)),
				'wav': QtBind.text(gui, txtWav),
			}, f, indent=2)
		log('[%s] Saved.' % pName)
	except Exception as e:
		log('[%s] Save error: %s' % (pName, e))


def load_config():
	try:
		QtBind.setChecked(gui, chkSound, True)   # default: alert on
	except Exception:
		pass
	try:
		if os.path.isfile(CONFIG_FILE):
			with open(CONFIG_FILE, 'r') as f:
				data = json.load(f)
			QtBind.setText(gui, txtLines, str(data.get('lines', 4)))
			QtBind.setText(gui, txtDelay, str(data.get('delay', 2000)))
			try:
				QtBind.setChecked(gui, chkSound, bool(data.get('sound', True)))
				QtBind.setText(gui, txtWav, str(data.get('wav', '')))
			except Exception:
				pass
	except Exception as e:
		log('[%s] Config read error: %s' % (pName, e))


# ============================================================================
# Inventory / items
# ============================================================================
def on_refresh():
	current_items.clear()
	QtBind.clear(gui, combo_items)
	inv = get_inventory()
	if not inv or 'items' not in inv:
		log('[%s] You are not in game.' % pName)
		return
	idx = 0
	for slot, item in enumerate(inv['items']):
		if not item or slot <= 13:  # skip equipment
			continue
		try:
			ref = get_item(item['model'])
		except Exception:
			ref = None
		# only equippable items (tid1 == 1)
		if ref and ref.get('tid1') == 1:
			current_items[idx] = slot
			idx += 1
			QtBind.append(gui, combo_items, '%s +%s' % (item['name'], item.get('plus', 0)))
	log('[%s] %d equippable items in inventory.' % (pName, idx))


def selected_item_slot():
	if not current_items:
		return None
	i = QtBind.currentIndex(gui, combo_items)
	return current_items.get(i)


def current_target_slot():
	if queue:
		return queue[0][0]
	return selected_item_slot()


def on_add_queue():
	slot = selected_item_slot()
	if slot is None:
		log('[%s] Select an item (Refresh) before adding it to the queue.' % pName)
		return
	inv = get_inventory()
	it = inv['items'][slot] if (inv and 'items' in inv and slot < len(inv['items'])) else None
	label = ('%s +%s' % (it.get('name', '?'), it.get('plus', 0))) if isinstance(it, dict) else ('slot %d' % slot)
	queue.append((slot, label))
	QtBind.append(gui, lstQueue, label)
	log('[%s] Added to queue: %s. Total: %d.' % (pName, label, len(queue)))


def on_clear_queue():
	queue[:] = []
	try:
		QtBind.clear(gui, lstQueue)
	except Exception:
		pass
	log('[%s] Queue cleared.' % pName)


def get_wheel_slot():
	inv = get_inventory()
	if not inv or 'items' not in inv:
		return -1
	for slot, item in enumerate(inv['items']):
		if item and 'Wheel of Fate' in (item.get('name') or ''):
			return slot
	return -1


# ============================================================================
# Start / Stop
# ============================================================================
def on_startstop():
	global started
	if not started:
		if not queue and selected_item_slot() is None:
			log('[%s] Select an item (Refresh) or add one to the queue.' % pName)
			return
		started = True
		QtBind.setText(gui, btnStartStop, '      Stop      ')
		clear_history()
		set_status('running')
		use_wheel()
	else:
		stop()


def stop():
	global started, _timer
	started = False
	QtBind.setText(gui, btnStartStop, '      Start      ')
	if _timer:
		try:
			_timer.cancel()
		except Exception:
			pass
		_timer = None
	set_status('stopped')


# ============================================================================
# Use Wheel of Fate
# ============================================================================
def use_wheel():
	global attempts
	if not started:
		return
	item_slot = current_target_slot()
	if item_slot is None:
		log('[%s] No item selected.' % pName)
		stop()
		return
	wheel_slot = get_wheel_slot()
	if wheel_slot == -1:
		log('[%s] No Wheel of Fate left in inventory. Stopping.' % pName)
		stop()
		return
	p = b'\x02\x19\x02'
	p += struct.pack('B', item_slot)
	p += struct.pack('B', wheel_slot)
	inject_joymax(WHEEL_OPCODE, p, False)
	attempts += 1
	log('[%s] Wheel of Fate #%d (item slot %d).' % (pName, attempts, item_slot))


# ============================================================================
# Wheel response: count the lines and stop at target
# ============================================================================
def handle_joymax(opcode, data):
	global _timer
	if opcode == WHEEL_RESULT_OPCODE and started:
		try:
			is_success = data[2]
			if is_success == 1 and len(data) > MAGICLINES_OFFSET:
				number_of_magiclines = data[MAGICLINES_OFFSET]
				blue = max(0, number_of_magiclines - 1)  # the first line is the base one
				target = lines_target()
				set_status('%d lines (target %d), attempt %d' % (blue, target, attempts))
				add_history('Attempt %d:  %d lines' % (attempts, blue))
				log('[%s] Result: raw=%d, blue=%d (target %d).' % (pName, number_of_magiclines, blue, target))
				if blue >= target:
					log('[%s] Item done: %d lines >= %d.' % (pName, blue, target))
					play_alert()
					if queue:
						done = queue.pop(0)
						try:
							QtBind.remove(gui, lstQueue, done[1])
						except Exception:
							pass
						log('[%s] Removed from queue: %s. Remaining: %d.' % (pName, done[1], len(queue)))
					if queue:
						if started:
							_timer = Timer(delay_sec(), use_wheel)
							_timer.start()
					else:
						log('[%s] DONE! Queue finished.' % pName)
						stop()
				elif started:
					_timer = Timer(delay_sec(), use_wheel)
					_timer.start()
			else:
				# unsuccessful response -> retry after delay
				if started:
					_timer = Timer(delay_sec(), use_wheel)
					_timer.start()
		except Exception as e:
			log('[%s] Response parse error: %s' % (pName, e))
	return True


# ============================================================================
# Init
# ============================================================================
load_config()
log('[%s] v%s loaded OK.' % (pName, pVersion))
WheelOfFate.py v1.2 python · 310 lines · 9,514 B · 2d ago Download
# -*- coding: utf-8 -*-
#
# WheelOfFate - plugin for phBot  (iSRO-R)
# ----------------------------------------
# Automatically uses "Wheel of Fate" on an item and stops when the item has a
# number of magic (blue) lines >= the value you choose. The VALUES of the blue
# lines do not matter, only HOW MANY lines there are.
#
# NOTE: every spin consumes one Wheel of Fate. The first spin on an item always
# happens (the plugin cannot read the current lines without spinning once).
#
from phBot import *
from threading import Timer
import struct
import QtBind
import os
import json

pName = 'WheelOfFate'
pVersion = '1.2'

# wheel use packet (same as AutoWheels): 0x7151
WHEEL_OPCODE = 0x7151
WHEEL_RESULT_OPCODE = 0xB151
# offset of number_of_magiclines inside the 0xB151 response
MAGICLINES_OFFSET = 26

CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), pName + '.json')

started = False
_timer = None
current_items = {}   # combo index -> inventory slot
attempts = 0
queue = []           # (slot, label) items waiting
history = []         # result lines, newest first

# ============================================================================
# GUI
# ============================================================================
gui = QtBind.init(__name__, pName)

# ---- Title ----
QtBind.createLabel(gui, 'W H E E L   O F   F A T E        v%s' % pVersion, 10, 6)

# ---- Item selection ----
QtBind.createLabel(gui, 'Item to spin:', 10, 30)
combo_items = QtBind.createCombobox(gui, 10, 48, 240, 22)
QtBind.createButton(gui, 'on_refresh', '  Refresh items  ', 258, 47)
QtBind.createButton(gui, 'on_add_queue', '  + Queue  ', 258, 74)
QtBind.createButton(gui, 'on_clear_queue', '  Clear queue  ', 360, 74)

# ---- Queue (right, under Clear queue) ----
QtBind.createLabel(gui, 'Queue (remaining):', 360, 104)
lstQueue = QtBind.createList(gui, 360, 124, 210, 176)

# ---- Settings ----
QtBind.createLabel(gui, 'Stop at blue lines >=:', 10, 102)
txtLines = QtBind.createLineEdit(gui, '4', 210, 100, 40, 20)

QtBind.createLabel(gui, 'Delay between wheels (ms):', 10, 126)
txtDelay = QtBind.createLineEdit(gui, '2000', 210, 124, 50, 20)
QtBind.createButton(gui, 'on_save', '  Save  ', 280, 123)

btnStartStop = QtBind.createButton(gui, 'on_startstop', '      Start      ', 10, 150)
lblStatus = QtBind.createLabel(gui, 'Status: stopped', 150, 154)

# ---- History (left) : newest line on top, scroll for the older ones ----
QtBind.createLabel(gui, 'History (attempt: lines):', 10, 182)
lstResult = QtBind.createList(gui, 10, 200, 240, 44)

# ---- Footer ----
QtBind.createLabel(gui, 'Author : CK3 @ iLog1K.net', 10, 258)


def set_status(txt):
	QtBind.setText(gui, lblStatus, 'Status: ' + txt)


def add_history(text):
	"""Newest result on the first row; older ones below (scroll to see them)."""
	history.insert(0, text)
	try:
		QtBind.clear(gui, lstResult)
		for line in history:
			QtBind.append(gui, lstResult, line)
	except Exception:
		pass


def clear_history():
	history[:] = []
	try:
		QtBind.clear(gui, lstResult)
	except Exception:
		pass


def lines_target():
	try:
		return max(1, int(QtBind.text(gui, txtLines).strip()))
	except Exception:
		return 4


def delay_sec():
	try:
		return max(0.0, int(QtBind.text(gui, txtDelay).strip()) / 1000.0)
	except Exception:
		return 2.0


# ============================================================================
# Config
# ============================================================================
def on_save(*_):
	try:
		with open(CONFIG_FILE, 'w') as f:
			json.dump({'lines': lines_target(), 'delay': int(delay_sec() * 1000)}, f, indent=2)
		log('[%s] Saved.' % pName)
	except Exception as e:
		log('[%s] Save error: %s' % (pName, e))


def load_config():
	try:
		if os.path.isfile(CONFIG_FILE):
			with open(CONFIG_FILE, 'r') as f:
				data = json.load(f)
			QtBind.setText(gui, txtLines, str(data.get('lines', 4)))
			QtBind.setText(gui, txtDelay, str(data.get('delay', 2000)))
	except Exception as e:
		log('[%s] Config read error: %s' % (pName, e))


# ============================================================================
# Inventory / items
# ============================================================================
def on_refresh():
	current_items.clear()
	QtBind.clear(gui, combo_items)
	inv = get_inventory()
	if not inv or 'items' not in inv:
		log('[%s] You are not in game.' % pName)
		return
	idx = 0
	for slot, item in enumerate(inv['items']):
		if not item or slot <= 13:  # skip equipment
			continue
		try:
			ref = get_item(item['model'])
		except Exception:
			ref = None
		# only equippable items (tid1 == 1)
		if ref and ref.get('tid1') == 1:
			current_items[idx] = slot
			idx += 1
			QtBind.append(gui, combo_items, '%s +%s' % (item['name'], item.get('plus', 0)))
	log('[%s] %d equippable items in inventory.' % (pName, idx))


def selected_item_slot():
	if not current_items:
		return None
	i = QtBind.currentIndex(gui, combo_items)
	return current_items.get(i)


def current_target_slot():
	if queue:
		return queue[0][0]
	return selected_item_slot()


def on_add_queue():
	slot = selected_item_slot()
	if slot is None:
		log('[%s] Select an item (Refresh) before adding it to the queue.' % pName)
		return
	inv = get_inventory()
	it = inv['items'][slot] if (inv and 'items' in inv and slot < len(inv['items'])) else None
	label = ('%s +%s' % (it.get('name', '?'), it.get('plus', 0))) if isinstance(it, dict) else ('slot %d' % slot)
	queue.append((slot, label))
	QtBind.append(gui, lstQueue, label)
	log('[%s] Added to queue: %s. Total: %d.' % (pName, label, len(queue)))


def on_clear_queue():
	queue[:] = []
	try:
		QtBind.clear(gui, lstQueue)
	except Exception:
		pass
	log('[%s] Queue cleared.' % pName)


def get_wheel_slot():
	inv = get_inventory()
	if not inv or 'items' not in inv:
		return -1
	for slot, item in enumerate(inv['items']):
		if item and 'Wheel of Fate' in (item.get('name') or ''):
			return slot
	return -1


# ============================================================================
# Start / Stop
# ============================================================================
def on_startstop():
	global started
	if not started:
		if not queue and selected_item_slot() is None:
			log('[%s] Select an item (Refresh) or add one to the queue.' % pName)
			return
		started = True
		QtBind.setText(gui, btnStartStop, '      Stop      ')
		clear_history()
		set_status('running')
		use_wheel()
	else:
		stop()


def stop():
	global started, _timer
	started = False
	QtBind.setText(gui, btnStartStop, '      Start      ')
	if _timer:
		try:
			_timer.cancel()
		except Exception:
			pass
		_timer = None
	set_status('stopped')


# ============================================================================
# Use Wheel of Fate
# ============================================================================
def use_wheel():
	global attempts
	if not started:
		return
	item_slot = current_target_slot()
	if item_slot is None:
		log('[%s] No item selected.' % pName)
		stop()
		return
	wheel_slot = get_wheel_slot()
	if wheel_slot == -1:
		log('[%s] No Wheel of Fate left in inventory. Stopping.' % pName)
		stop()
		return
	p = b'\x02\x19\x02'
	p += struct.pack('B', item_slot)
	p += struct.pack('B', wheel_slot)
	inject_joymax(WHEEL_OPCODE, p, False)
	attempts += 1
	log('[%s] Wheel of Fate #%d (item slot %d).' % (pName, attempts, item_slot))


# ============================================================================
# Wheel response: count the lines and stop at target
# ============================================================================
def handle_joymax(opcode, data):
	global _timer
	if opcode == WHEEL_RESULT_OPCODE and started:
		try:
			is_success = data[2]
			if is_success == 1 and len(data) > MAGICLINES_OFFSET:
				number_of_magiclines = data[MAGICLINES_OFFSET]
				blue = max(0, number_of_magiclines - 1)  # the first line is the base one
				target = lines_target()
				set_status('%d lines (target %d), attempt %d' % (blue, target, attempts))
				add_history('Attempt %d:  %d lines' % (attempts, blue))
				log('[%s] Result: raw=%d, blue=%d (target %d).' % (pName, number_of_magiclines, blue, target))
				if blue >= target:
					log('[%s] Item done: %d lines >= %d.' % (pName, blue, target))
					if queue:
						done = queue.pop(0)
						try:
							QtBind.remove(gui, lstQueue, done[1])
						except Exception:
							pass
						log('[%s] Removed from queue: %s. Remaining: %d.' % (pName, done[1], len(queue)))
					if queue:
						if started:
							_timer = Timer(delay_sec(), use_wheel)
							_timer.start()
					else:
						log('[%s] DONE! Queue finished.' % pName)
						stop()
				elif started:
					_timer = Timer(delay_sec(), use_wheel)
					_timer.start()
			else:
				# unsuccessful response -> retry after delay
				if started:
					_timer = Timer(delay_sec(), use_wheel)
					_timer.start()
		except Exception as e:
			log('[%s] Response parse error: %s' % (pName, e))
	return True


# ============================================================================
# Init
# ============================================================================
load_config()
log('[%s] v%s loaded OK.' % (pName, pVersion))
Sign in to propose something or back an existing proposal.
No proposals yet. Be the first to suggest something.
Sign in to join the discussion.
No comments yet.
Sign in to report a bug.
No bug reports. Nice.