plugin iSRO-R v2.1

Shining Stone

by @CK3 · 1d ago · updated 1d ago · 15 views

Crafting automatic all Shining Stones from your inventory.

Changelog

v2.1 latest files only

Released with files, no release notes written yet.

ShiningStone.py v2.1 python · 443 lines · 13,112 B · 1d ago Download
# -*- coding: utf-8 -*-
#
# ShiningStone - plugin for phBot
# -------------------------------
# Crafts "Shining Stone" from Blue Stone + Black Stone, over and over.
#
# The craft packet (opcode 0x7538) is replayed on a loop, with the inventory
# slots of the two materials patched in on every round:
#
#   01 | uint32 | uint16 len | recipe codename | mat count | slot | slot | qty
#
from phBot import *
from threading import Timer
import QtBind
import os
import json

pName = 'ShiningStone'
pVersion = '2.1'

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

# recorded craft packet - used when the json has no recipe of its own
DEFAULT_RECIPE = [[0x7538, '011d0000001f004d4b5f52435f54524144455f4d41544552'
						   '49414c5f4c4947485453544f4e4502454901']]
DEFAULT_BLUE_SLOT = 69
DEFAULT_BLACK_SLOT = 73

started = False
_timer = None
rounds = 0

# per-quality statistics: {exact item name: crafted so far}
stats = {}
fails = 0
_prev_counts = {}

# craft packets + the slots the materials were in when they were recorded
recipe = list(DEFAULT_RECIPE)
learn_blue = DEFAULT_BLUE_SLOT
learn_black = DEFAULT_BLACK_SLOT
_step = 0

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

QtBind.createLabel(gui, 'S H I N I N G   S T O N E        v%s' % pVersion, 10, 6)

QtBind.createLabel(gui, 'Material 1 (name contains):', 10, 34)
txtBlue = QtBind.createLineEdit(gui, 'Blue Stone', 175, 32, 150, 20)
QtBind.createLabel(gui, 'Material 2 (name contains):', 10, 58)
txtBlack = QtBind.createLineEdit(gui, 'Black Stone', 175, 56, 150, 20)
QtBind.createLabel(gui, 'Result (name contains):', 10, 82)
txtResult = QtBind.createLineEdit(gui, 'Shining Stone', 175, 80, 150, 20)

QtBind.createLabel(gui, 'Delay between crafts (ms):', 10, 112)
txtDelay = QtBind.createLineEdit(gui, '3000', 175, 110, 55, 20)
QtBind.createLabel(gui, 'Delay between packets (ms):', 10, 136)
txtStepDelay = QtBind.createLineEdit(gui, '500', 175, 134, 55, 20)
QtBind.createLabel(gui, 'Stop after N crafts (0 = all):', 10, 160)
txtCount = QtBind.createLineEdit(gui, '0', 175, 158, 55, 20)

chkStopEmpty = QtBind.createCheckBox(gui, 'on_dummy', 'Stop when a material runs out', 10, 186)

QtBind.createButton(gui, 'on_check', ' Check materials ', 10, 214)
QtBind.createButton(gui, 'on_save', '  Save  ', 145, 214)
QtBind.createButton(gui, 'on_stats', '  Stats  ', 220, 214)
QtBind.createButton(gui, 'on_reset_stats', ' Reset stats ', 290, 214)

btnStartStop = QtBind.createButton(gui, 'on_startstop', '     Start     ', 10, 244)
lblStatus = QtBind.createLabel(gui, 'stopped', 120, 248)

QtBind.createLabel(gui, 'Log:', 420, 30)
lstLog = QtBind.createList(gui, 420, 50, 270, 105)

QtBind.createLabel(gui, 'Stats:', 420, 160)
lstStats = QtBind.createList(gui, 420, 178, 270, 82)

QtBind.createLabel(gui, 'Author : CK3 @ iLog1K.net', 10, 272)


# ============================================================================
# Helpers
# ============================================================================
def on_dummy(*_):
	pass


def set_status(txt):
	try:
		QtBind.setText(gui, lblStatus, txt)
	except Exception:
		pass


def add_log(text):
	log('[%s] %s' % (pName, text))
	try:
		QtBind.append(gui, lstLog, text)
	except Exception:
		pass


def add_stat(text):
	try:
		QtBind.append(gui, lstStats, text)
	except Exception:
		pass


def clear_stats_box():
	"""QtBind has no documented clear(), so try what exists and fall back to a
	separator line if the list cannot be emptied."""
	for fname in ('clearList', 'clear', 'clearItems'):
		fn = getattr(QtBind, fname, None)
		if fn:
			try:
				fn(gui, lstStats)
				return
			except Exception:
				pass
	add_stat('-' * 34)


def _int(widget, default):
	try:
		return max(0, int(QtBind.text(gui, widget).strip()))
	except Exception:
		return default


def delay_sec():
	return _int(txtDelay, 3000) / 1000.0


def step_delay_sec():
	return _int(txtStepDelay, 500) / 1000.0


def name_blue():
	return QtBind.text(gui, txtBlue).strip()


def name_black():
	return QtBind.text(gui, txtBlack).strip()


def name_result():
	return QtBind.text(gui, txtResult).strip()


def find_item(namepart):
	"""Returns (first_slot, total_quantity) for an item whose name contains
	namepart. (None, 0) when not found."""
	if not namepart:
		return None, 0
	inv = get_inventory()
	if not inv or 'items' not in inv:
		return None, 0
	first = None
	total = 0
	low = namepart.lower()
	for slot, item in enumerate(inv['items']):
		if not item or slot <= 12:
			continue
		if low in (item.get('name') or '').lower():
			if first is None:
				first = slot
			total += int(item.get('quantity', 1) or 1)
	return first, total


def count_by_name(namepart):
	"""{exact item name: total quantity} for every stack matching namepart."""
	res = {}
	if not namepart:
		return res
	inv = get_inventory()
	if not inv or 'items' not in inv:
		return res
	low = namepart.lower()
	for slot, item in enumerate(inv['items']):
		if not item or slot <= 12:
			continue
		nm = item.get('name') or ''
		if low in nm.lower():
			res[nm] = res.get(nm, 0) + int(item.get('quantity', 1) or 1)
	return res


def on_stats():
	clear_stats_box()

	# what is in the inventory right now, per quality
	cur = count_by_name(name_result())
	add_stat('--- in inventory ---')
	if cur:
		for nm, q in sorted(cur.items(), key=lambda kv: -kv[1]):
			add_stat('  %s : %d' % (nm, q))
		add_stat('  TOTAL : %d' % sum(cur.values()))
	else:
		add_stat('  none')

	# what this session actually produced
	total = sum(stats.values())
	if not total:
		add_stat('--- crafted this session: nothing yet ---')
		return
	add_stat('--- crafted this session: %d in %d round(s) ---' % (total, rounds))
	for nm, q in sorted(stats.items(), key=lambda kv: -kv[1]):
		add_stat('  %s : %d  (%.1f%%)' % (nm, q, 100.0 * q / total))
	if fails:
		add_stat('  rounds with no gain: %d  (%.1f%%)'
				% (fails, 100.0 * fails / max(1, rounds)))


def on_reset_stats():
	global stats, fails, _prev_counts
	stats = {}
	fails = 0
	_prev_counts = count_by_name(name_result())
	add_log('Stats reset.')


def on_check():
	bs, bq = find_item(name_blue())
	ks, kq = find_item(name_black())
	_rs, rq = find_item(name_result())
	add_log('%s: slot %s x%d | %s: slot %s x%d | %s: x%d' %
			(name_blue(), bs, bq, name_black(), ks, kq, name_result(), rq))


# ============================================================================
# Config
# ============================================================================
def on_save(*_):
	try:
		data = {
			'blue': name_blue(),
			'black': name_black(),
			'result': name_result(),
			'delay': _int(txtDelay, 3000),
			'step_delay': _int(txtStepDelay, 500),
			'count': _int(txtCount, 0),
			'stop_empty': bool(QtBind.isChecked(gui, chkStopEmpty)),
			'recipe': recipe,
			'learn_blue': learn_blue,
			'learn_black': learn_black,
		}
		with open(CONFIG_FILE, 'w') as f:
			json.dump(data, f, indent=2)
		add_log('Saved.')
	except Exception as e:
		add_log('Save error: %s' % e)


def load_config():
	global recipe, learn_blue, learn_black
	try:
		if not os.path.isfile(CONFIG_FILE):
			QtBind.setChecked(gui, chkStopEmpty, True)
			return
		with open(CONFIG_FILE, 'r') as f:
			data = json.load(f)
		QtBind.setText(gui, txtBlue, str(data.get('blue', 'Blue Stone')))
		QtBind.setText(gui, txtBlack, str(data.get('black', 'Black Stone')))
		QtBind.setText(gui, txtResult, str(data.get('result', 'Shining Stone')))
		QtBind.setText(gui, txtDelay, str(data.get('delay', 3000)))
		QtBind.setText(gui, txtStepDelay, str(data.get('step_delay', 500)))
		QtBind.setText(gui, txtCount, str(data.get('count', 0)))
		QtBind.setChecked(gui, chkStopEmpty, bool(data.get('stop_empty', True)))
		recipe = data.get('recipe') or list(DEFAULT_RECIPE)
		learn_blue = int(data.get('learn_blue', DEFAULT_BLUE_SLOT))
		learn_black = int(data.get('learn_black', DEFAULT_BLACK_SLOT))
	except Exception as e:
		add_log('Config read error: %s' % e)


# ============================================================================
# Replay
# ============================================================================
def scan_start(raw):
	"""Offset where slot patching may begin.

	The craft packet is  flag | uint32 | uint16 len | recipe codename | mats...
	and the codename is plain ascii, so letters like 'E' (0x45) / 'I' (0x49)
	collide with slot numbers. Never touch anything before the codename ends.
	"""
	try:
		if len(raw) >= 7:
			slen = raw[5] | (raw[6] << 8)
			end = 7 + slen
			if 0 < slen and end <= len(raw) and all(32 <= b < 127 for b in raw[7:end]):
				return end
	except Exception:
		pass
	return 0


def patch_payload(raw, blue_slot, black_slot):
	"""Replace the slot bytes captured while recording with the current ones."""
	out = bytearray(raw)
	if blue_slot == learn_blue and black_slot == learn_black:
		return bytes(out)          # slots did not move, replay as recorded
	hits = []
	for i in range(scan_start(raw), len(out)):
		if learn_blue >= 0 and out[i] == learn_blue and blue_slot is not None:
			out[i] = blue_slot
			hits.append('%d:blue' % i)
		elif learn_black >= 0 and out[i] == learn_black and black_slot is not None:
			out[i] = black_slot
			hits.append('%d:black' % i)
	if hits:
		add_log('slots moved, patched offsets %s' % ', '.join(hits))
	return bytes(out)


def send_step():
	global _timer, _step
	if not started:
		return
	if _step >= len(recipe):
		# whole sequence sent -> wait, then check the result and go again
		_timer = Timer(delay_sec(), finish_round)
		_timer.start()
		return
	blue_slot, _bq = find_item(name_blue())
	black_slot, _kq = find_item(name_black())
	op, hx = recipe[_step]
	try:
		raw = bytes.fromhex(hx)
	except Exception:
		add_log('Corrupt recipe packet, stopping.')
		stop()
		return
	inject_joymax(op, patch_payload(raw, blue_slot, black_slot), False)
	_step += 1
	_timer = Timer(step_delay_sec(), send_step)
	_timer.start()


def finish_round():
	global _timer, rounds, fails, _prev_counts
	if not started:
		return
	rounds += 1

	# what changed in the inventory since the previous round tells us which
	# quality dropped - no need to parse the crafting chat message
	cur = count_by_name(name_result())
	gains = []
	for nm, q in cur.items():
		diff = q - _prev_counts.get(nm, 0)
		if diff > 0:
			stats[nm] = stats.get(nm, 0) + diff
			gains.append('%s +%d' % (nm, diff))
	_prev_counts = cur
	if not gains:
		fails += 1

	rq = sum(cur.values())
	_bs, bq = find_item(name_blue())
	_ks, kq = find_item(name_black())
	set_status('round %d  |  %s x%d  |  mats %d / %d' % (rounds, name_result(), rq, bq, kq))
	add_log('Round %d: %s  (total x%d, mats left: %d / %d)'
			% (rounds, ', '.join(gains) if gains else 'nothing gained', rq, bq, kq))
	if rounds % 25 == 0:
		on_stats()

	target = _int(txtCount, 0)
	if target > 0 and rounds >= target:
		add_log('Target reached (%d crafts). Stopping.' % target)
		stop()
		return
	if QtBind.isChecked(gui, chkStopEmpty) and (bq <= 0 or kq <= 0):
		add_log('Out of materials. Stopping.')
		stop()
		return
	craft_once()


def craft_once():
	global _step
	if not started:
		return
	blue_slot, _bq = find_item(name_blue())
	black_slot, _kq = find_item(name_black())
	if blue_slot is None or black_slot is None:
		add_log('Material missing in inventory. Stopping.')
		stop()
		return
	_step = 0
	send_step()


# ============================================================================
# Start / Stop
# ============================================================================
def on_startstop():
	global started, rounds
	if started:
		stop()
		return
	if not recipe:
		add_log('No craft packet available.')
		return
	blue_slot, bq = find_item(name_blue())
	black_slot, kq = find_item(name_black())
	if blue_slot is None or black_slot is None:
		add_log('Materials not in inventory (%s / %s).' % (name_blue(), name_black()))
		return
	started = True
	rounds = 0
	on_reset_stats()          # also takes the baseline inventory snapshot
	QtBind.setText(gui, btnStartStop, '     Stop     ')
	set_status('running')
	add_log('Started. Materials: %d / %d.' % (bq, kq))
	craft_once()


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


# ============================================================================
# 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.