#!/usr/bin/python
r"""I+A=B - I too A to B - version 0.04
Emulator for the language used in the Artless Games game A=B.
With a GUI that detects changes to A=B save-files, and loads that file,
together with the corresponding task, and attempts to run it.

This is basically the same as what is done within the A=B program, but
this emulator is faster, which can be useful for some complex tasks.
Just keep this window beside the A=B window, and whenever a program is
modified, press one of the "play" buttons to save it, which will cause
the program to load and run in here too.

Your personal path to the save-file-folder on your system should be specified
in the i2a2b.cfg configuration file. Example:
savepath=C:\Program Files (x86)\Steam\steamapps\common\A2B\A2B_Data\71231231231231237\0
(You may also specify the taskpath, but it is usually auto-set.)

This should probably be considered a beta-version. It lacks a lot if
error-handling and a lot of the code is a bit "untidy" :-)
Though as far as I know the emulator works just like the real program.

I have no affiliation with Artless Games, and the name "A=B" and everything
else related to the game is their property. This script is freeware.

Changelog:
0.04 2025-07-06
	Abort if a forever-loop is detected.
	Abort if output exceeds 255 characters.
	Abort if executions takes too long time.
	Accept two digits in filename as valid task number.
"""
import time
import os
import sys 

import tkinter as tk
from tkinter.constants import *
from tkinter import ttk
from tkinter import scrolledtext

class I2a2b:
	def __init__(self, cfg, verbose=False, quiet=False):
		""" Initiate the A=2 emulator and task-runner.
		cfg is a dict with at least two items;
		'taskpath' is a full path to the A2B_Data/task folder, and
		'savepath' to the personal save-file folder.
		
		Verbosity is set with the verbose (-v) and
		quiet (-q) arguments, according to this:
		(-- means neither -v nor -q)
		
		-q no list of testcases
		-- list testcases
		-v list testcases
		-q -v same as -q
		
		-v log execution
		-- log execution on fail
		-q never log execution
		-q -v Log execution on fail
		"""
		self.title = "-"
		self.task = ""
		self.pgm = ""
		self.nrpgm = ""
		self.testcases = []
		self.savepath = cfg['savepath']
		self.taskpath = cfg['taskpath']
		
		self.verbose = verbose
		self.quiet = quiet
		self.cmds = []
	
	def log(self, msg):
		""" Print or log a message."""
		print(msg)
	
	def find_file(self, path, pfx, sfx):
		""" Return the filename of a file in path that starts with pfx
		and ends with sfx. Raise IOError if no file match.
		"""
		for fnam in os.listdir(path):
			if fnam.startswith(pfx) and fnam.endswith(sfx):
				filename = path + os.sep + fnam
				return filename
		
		raise IOError("Could not find %s*%s in %s" % (pfx, sfx, path))
	
	def load_task(self, chap, epi):
		""" Load one task from the program folder, by chapter and nr."""
		pfx = "c%s_%s_" % (chap, epi)
		filename = self.find_file(self.taskpath, pfx, ".a2b")
		self.load_taskfile(filename)
	
	def load_taskfile(self, filename):
		""" Load the specified task-file."""
		import json
		
		with open(filename, "rb") as fp:
			x = json.load(fp)
			self.title = x["title_en"]
			self.task = "%s\n%s\n" % (self.title, x["quest_en"])
			if not self.quiet:
				self.log("======= %s =======" % (self.title,))
				self.log(x["quest_en"])
				self.log("")
			
			self.testcases = zip(x["input"], x["output"])
	
	def load_save(self, chap, epi, slot="1"):
		""" Load a program from a save-slot for the specified task."""
		pfx = "c%s_%s_" % (chap, epi)
		sfx = "%s.sav" % (slot,)
		filename = self.find_file(self.savepath, pfx, sfx)
		self.load_program(filename)
	
	def load_program(self, filename):
		""" Load a program from a text-file."""
		with open(filename) as fp:
			pgm = fp.read()
		
		self.parse(pgm)
	
	def parse(self, pgm):
		""" Parse a program, supplied as a blob of text."""
		n = 0
		if self.verbose and not self.quiet:
			self.log("    +------- Program: -------")
		
		self.pgm = pgm
		txts = []
		self.cmds = []
		for hrow in pgm.split("\n"):
			hrow = hrow.rstrip("\r")
			row = hrow.split("#", 1)[0].rstrip(" ")
			txts.append("%3s | %s" % ("" if "=" not in row else n+1, hrow))
			if self.verbose and not self.quiet:
				self.log(txts[-1])
			
			if "=" not in row:
				continue
			
			n += 1
			fnd, rpl = row.split("=", 1)
			if ")" in fnd:
				f1, f2 = fnd.split(")", 1)
			
			else:
				f1, f2 = "", fnd
			
			if ")" in rpl:
				r1, r2 = rpl.split(")", 1)
			
			else:
				r1, r2 = "", rpl
			
			self.cmds.append((n + 1, f1, f2, r1, r2))
		
		if self.verbose and not self.quiet:
			self.log("    +------------------------")
		
		self.nrpgm = "\n".join(txts)
	
	def once(self, out, exp, nr, log=None, width=12):
		""" Run the loaded program once.
		
		Return (output, count, maxw)
		output is the final iutput string after the run.
		count is number of executed commands.
		maxw is the maximum width of the variable during execution.
		"""
		if log is None:
			log = bool(self.verbose and not self.quiet)
		
		if log is True:
			log = self.log
		
		mycmds = self.cmds[:]
		seen = {}
		c = 0
		w = max(len(out), len(exp))
		if log:
			log("\n------- %d -------" % (nr,))
			log("%-*s # EXPECTED" % (width, exp,))
			log("%-*s # START" % (width, out,))
			# The number in the log below is (exec-count . row-number)
			# exec-count increases every time a command match and is executed
			# row-number is non-comment-lines, starting at 1 (same as A=B program GUI)
		
		# The commands, or whatever one can call them, are not documented here at all.
		# This is intentional - the documentation is available in the A=B game, just like all tasks.
		# To use this emulator, you should first buy the A=B game.
		timeout = time.time() + 10
		while time.time() < timeout:
			for i, cmd in enumerate(mycmds):
				if len(out) > 255:
					if log:
						log("ABORTED: Output exceeds 255 characters")
					
					return out, c, w
				
				(n, f1, f2, r1, r2) = cmd
				if f1 == "(once":
					seen = {}
					once = True
					f1 = ""
				
				else:
					once = False
				
				if f1 == "(start" and out.startswith(f2) \
				 or f1 == "(end" and out.endswith(f2) \
				 or f1 == "" and f2 in out:
					c += 1
					if r1 == "(return":
						out = r2
						w = max(w, len(out))
						if log:
							log("%-*s # %3d.%d: %s%s%s=%s%s%s" % (
								width, out, c, n, f1, f1 and ")", f2, r1, r1 and ")", r2))
						
						return out, c, w
					
					rpl = r2 if r1 == "" else ""
					if f1 == "(end":
						p = out.rindex(f2)
						rpl = out[:p] + out[p:].replace(f2, rpl, 1)
					
					else:
						rpl = out.replace(f2, rpl, 1)
						
					out = (r2 if r1 == "(start" else "") + rpl + (r2 if r1 == "(end" else "")
					w = max(w, len(out))
					if log:
						log("%-*s # %3d.%d: %s%s%s=%s%s%s" % (
							width, out, c, n, f1, f1 and ")", f2, r1, r1 and ")", r2))
					
					if once:
						del mycmds[i]
					
					elif out in seen:
						# Caught in a forever-loop
						if log:
							log("ABORTED: Would loop forever")
						
						return out, c, w
					
					else:
						seen[out] = True
					
					break
			
			else:
				# No command matched
				return out, c, w
		
		if log:
			log("ABORTED: Took too long to execute")
		
		return out, c, w
	
	def run(self, basenr=1, logger=None):
		""" Run the loaded program with all loaded test-cases,
		or until it fails.
		Return False if it fails, othwerwise True.
		
		After a successful run it loggs "ALL SUCCESS" with some
		statistics; "(1 3 7 123) on 10". In the bracket are number
		of executed commands, (fewest, average, most, total), and
		on how many test-cases.
		"""
		log = bool(self.verbose and not self.quiet)
		logger = logger or self.log
		
		counts = []
		for n, (inp, exp) in enumerate(self.testcases):
			nr = basenr + n
			out, c, w = self.once(inp, exp, nr, log and logger)
			counts.append(c)
			fail = bool(out != exp)
			if fail and not log and (not self.quiet or self.verbose):
				# log fail even if not verbose, unless quiet
				self.once(inp, exp, nr, logger or True, width=w)
			
			if not self.quiet or fail:
				logger("%s%s # %d: %s %d %s" % (
					"" if self.verbose else inp + " => ",
					out, c, "FAIL" if fail else "SUCCESS", nr,
					self.title if self.quiet else ""))
			
			if fail:
				if not self.quiet:
					logger("FAILED: %s" % (self.title,))
				
				break
			
		
		else:
			if not counts:
				logger("NOTHING TO DO")
				
			else:
				avg = 1.0 * sum(counts) / len(counts)
				logger("ALL SUCCESS. (%d %.1f %d %d) on %d: %s" % (
					min(counts), avg, max(counts), sum(counts), len(counts), self.title))
			
			return True
		
		return False

class Watcher(object):
	running = None
	def __init__(self, path="."):
		self.last = {}
		self.path = os.path.abspath(path)
		for fname in os.listdir(self.path):
			self.last[fname] = os.stat(os.path.join(self.path, fname)).st_mtime
	
	# Look for changes
	def look(self):
		""" Look for changes in self.path, and if found, call the
		new_file or change_file methods.
		"""
		for fname in os.listdir(self.path):
			ts = os.stat(os.path.join(self.path, fname)).st_mtime
			if fname not in self.last:
				self.new_file(fname, ts)

			elif ts != self.last[fname]:
				self.change_file(fname, ts)

			else:
				continue

			self.last[fname] = ts
	
	#FIXME Do anyone need detection of deleted files?
	
	def new_file(self, fname, ts):
		self.change_file(fname, ts)
	
	def change_file(self, fname, ts):
		print("File changed: %s" % (fname,))
	
	# Keep watching in a loop        
	def watch(self, interval=0.5):
		""" Start a forever-loop that keeps calling the look method
		with the specified interval (seconds).
		"""
		self.running = True
		while self.running: 
			try: 
				# Look for changes
				time.sleep(interval)
				self.look()
			
			except Exception:
				print('Unhandled error: %s' % sys.exc_info()[0])
				raise

class Gui:
	def __init__(self, wintitle="I+A=B", a2b=None):
		self.root = tk.Tk()
		self.root.title(wintitle)
		
		def handle_quit(event=None):
			self.root.destroy()
		
		fr = ttk.Frame(self.root, padding=7)
		fr.grid(column=0, row=0, sticky=(N, W, E, S))
		#self.root.columnconfigure(0, weight=1)
		#self.root.rowconfigure(0, weight=1)

		self.txTsk = scrolledtext.ScrolledText(fr, height=7)
		self.txTsk.insert(END, "Waiting for changed save-file...")
		self.txVal = scrolledtext.ScrolledText(fr, height=7)
		self.txLog = scrolledtext.ScrolledText(fr, height=27)
		self.txPgm = scrolledtext.ScrolledText(fr, height=27)
		
		self.txTsk.grid(column=1, row=2)
		self.txVal.grid(column=2, row=2)
		self.txLog.grid(column=1, row=3)
		self.txPgm.grid(column=2, row=3, sticky=(N, W, E, S))
		
		quitbut = tk.Button(fr, text="Quit", command=handle_quit)
		quitbut.grid(column=1, row=4)
		self.root.bind("<Escape>", handle_quit)
		if not a2b:
			return
		
		self.a2b = a2b
		
		def handle_load():
			chap, epi = varTsk.get().split(".")
			self.a2b_load(chap, epi, varSlot.get() or "1")
			self.a2b_run()
		
		slots = ['1', '2', '3']
		choices = ['1.1', '1.2', '1.3', '1.4', '1.5', '1.6',
			'2.1', '2.2', '2.3', '2.4', '2.5', '2.6', '2.7', '2.8', '2.9',
			'3.1', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7',
			'4.1', '4.2', '4.3', '4.4', '4.5', '4.6', '4.7', '4.8', '4.9',
			'4.10', '4.11', '4.12', '4.13', '4.14', '4.15', '4.16',
			'5.1', '5.2', '5.3', '5.4', '5.5', '5.6',
			'6.1', '6.2', '6.3']
		varTsk = tk.StringVar(self.root)
		varTsk.set(choices[0])
		selTsk = ttk.Combobox(fr, values=choices, textvariable=varTsk)
		selTsk.grid(column=2, row=4, sticky=W)
		varSlot = tk.StringVar(self.root)
		varSlot.set(slots[0])
		selSlot = ttk.Combobox(fr, values=slots, textvariable=varSlot)
		selSlot.grid(column=2, row=4)
		loadbut = tk.Button(fr, text="Load+Run", command=handle_load)
		loadbut.grid(column=2, row=4, sticky=E)
	
	def logline(self, msg):
		self.txLog.insert(END, "%s\n" % (msg,))
		self.txLog.yview(END)
	
	def a2b_load(self, chap, epi, slot="1"):
		self.txVal.config(bg="white")
		self.txVal.delete("1.0", END)
		self.txPgm.delete("1.0", END)
		self.root.update()
		#self.a2b.load_program(os.path.join(self.path, fname))
		self.a2b.load_save(chap, epi, slot)
		self.txPgm.insert(END, self.a2b.nrpgm)
		self.txTsk.delete("1.0", END)
		self.a2b.load_task(chap, epi)
		self.txTsk.insert(END, a2b.task)
	
	def a2b_run(self):
		self.txLog.delete("1.0", END)
		self.root.update()
		success = self.a2b.run(logger=self.logline)
		self.txVal.delete("1.0", END)
		self.txVal.insert(END, "%s %s\n\n%s" % (a2b.title, time.ctime(), "SUCCESS" if success else "FAIL"))
		self.txVal.config(bg="green" if success else "red")

class MyWatcher(Watcher):
	gui = None
	def change_file(self, fname, ts):
		import re
		
		m = re.match(r"^c(\d)_(\d+)_.*(\d)\.sav$", fname)
		if not m:
			if fname != "globalSaveFile.sav":
				self.gui.logline("Ignore changed %s" % (fname,))
			
			return
		
		print("%s Loading %s..." % (time.ctime(), fname,))
		chap, epi, slot = m.groups()
		gui.a2b_load(chap, epi, slot)
		print("Loaded.")
		print("Will run task...")
		gui.a2b_run()
		print("Done.")
	
	def tick(self):
		self.look()
		self.gui.txTsk.after(500, self.tick)  # Repeat every 500 ms

def read_cfg():
	""" Read the configuration file, that holds the path to the personal
	savefiles in the A=B program (Usually below the steamapps-folder).
	It should be the whole path, including the two numbered sub-folders
	below the A2B_Data/save folder, where e.g. "c1_1_atob1.sav" resides.
	"""
	cfg = {}
	with open("i2a2b.cfg", "r") as fp:
		for row in fp:
			if "=" in row:
				key, val = row.strip().split("=", 1)
				cfg[key] = val
	
	if 'savepath' in cfg and 'datapath' not in cfg:
		find = os.sep + "A2B_Data" + os.sep
		p = cfg['savepath'].rfind(find)
		if p > 0:
			cfg['datapath'] = cfg['savepath'][:p + len(find) - 1]
	
	if 'datapath' in cfg and 'taskpath' not in cfg:
		cfg['taskpath'] = os.path.join(cfg['datapath'], "task")
	
	return cfg

if __name__ == '__main__':
	title = __doc__.split("\n", 1)[0]
	cfg = read_cfg()
	a2b = I2a2b(cfg, verbose=True, quiet=True)
	gui = Gui(title, a2b)
	watcher = MyWatcher(cfg['savepath'])
	watcher.gui = gui
	
	watcher.tick()
	gui.root.mainloop()
