Module:Grind/proc

From Fallen London Wiki

Documentation for this module may be created at Module:Grind/proc/doc

local p = {}

local util = require('Module:Grind/util')
local GRON = require('Module:Grind/GRON')
local fml = require('Module:Grind/fml')

-- == GRON processing ==

-- Checks if the GRON might have `resource` as an effect output.
-- Even if the function returns true, it may happen to be otherwise on evaluation.
-- The primary goal is to find whether it cannot be in the output under any circumstances.
local function is_source_of(gron, resource)
	local gtype = gron[1]
	if gtype == 'error' then
		-- returning false may cause the error to be deleted
		return true
	end
	if gtype == 'input' then
		return true
	end
	if gtype == 'action' then
		local success = gron['success'] or {}
		local failure = gron['failure'] or {}
		local alt_success = gron['alt_success'] or {}
		local alt_failure = gron['alt_failure'] or {}
		return (success[resource] or failure[resource] or alt_success[resource] or alt_failure[resource]) ~= nil
	end
	if gtype == 'seq' or gtype == 'airs' or gtype == 'req' or gtype == 'pswitch' or gtype == 'best' then
		for i = 2, #gron do
			if is_source_of(gron[i], resource) then
				return true
			end
		end
		return false
	end
	if gtype == 'gate' then
		-- could it be in the action?
		local target = gron['target']
		local g_resource = nil
		if type(target) == 'table' then
			g_resource = target[1]
		end
		local preserve_progress = util.s2b(gron['preserve_progress'], false)
		local action = gron['action']
		local check_action = false
		if resource and resource == g_resource then
			if preserve_progress then
				check_action = true
			end
		else
			check_action = true
		end
		if check_action and action then
			local action_source = is_source_of(action, resource)
			if action_source then
				return true
			end
		end
		-- could it be in the resolution?
		local resolution = gron['resolution']
		if resolution == nil then
			return false
		end
		return is_source_of(resolution, resource)
	end
	if gtype == 'filter' then
		local target = gron['target'] or {}
		if type(target) == 'string' then
			target = {target}
		end
		if type(target) ~= 'table' then
			return false
		end
		for _, r in ipairs(target) do
			if r == resource then
				local action = gron[2]
				if type(action) ~= 'table' then
					return is_source_of(action, resource)
				end
			end
		end
		return false
	end
	if gtype == 'sell' then
		local action = gron['action']
		if gron[resource] == nil then
			-- the resource is not sold, could it be in the action?
			if type(action) == 'table' then
				if is_source_of(action, resource) then
					return true
				end
			end
		end
		-- could it be a result of selling?
		for k, eff in pairs(gron) do
			if type(k) == 'string' and k ~= 'action' and k ~= 'market' and k ~= 'optimise' then
				if eff[resource] ~= nil then
					-- is it possible?
					if type(action) == 'table' then
						if is_source_of(action, k) then
							return true
						end
					end
				end
			end
		end
		return false
	end
	return false
end

-- Collects all the tables with reference ids into a cache.
local function scan_refs(gron, ref_cache)
	ref_cache = ref_cache or {}
	if type(gron) ~= 'table' then
		return ref_cache
	end
	if gron['id'] ~= nil then
		ref_cache[gron['id']] = gron
	end
	for key, val in pairs(gron) do
		ref_cache = scan_refs(val, ref_cache)
	end
	return ref_cache
end

-- Resolves (ref)erences.
-- Handles recursion automatically.
function p.resolve_refs(gron, ref_cache, inside_of)
	ref_cache = ref_cache or scan_refs(gron)
	inside_of = inside_of or {}
	if type(gron) ~= 'table' then
		return gron
	end
	if gron[1] == 'ref' then
		local id = gron[2]
		if inside_of[id] then
			return {'error', '(ref) recursion: ' .. id}
		end
		local _inside_of = mw.clone(inside_of)
		_inside_of[id] = true
		local ref = ref_cache[id]
		-- additional (ref)s might be inside of the resolved reference
		local ref = p.resolve_refs(ref, ref_cache, _inside_of)
		return ref
	end
	local new_gron = {}
	for key, val in pairs(gron) do
		if key ~= 'id' then
			new_gron[key] = p.resolve_refs(val, ref_cache, inside_of)
		end
	end
	return new_gron
end

-- Resolves (req)uirements.
-- `partial`: if true, only resolve those for which data is provided.
function p.resolve_reqs(gron, data, partial)
	partial = partial or false
	if type(gron) ~= 'table' then
		return gron
	end
	local gtype = gron[1]
	if gtype == 'req' then
		local target = gron['target']
		local is_input = util.s2b(gron['input'], false)
		local is_value = util.s2b(gron['value'], false)
		if is_input and is_value then
			return {'error', '(req).input and (req).value are mutually exclusive'}
		end
		local is_data = (not is_input) and (not is_value)
		if is_data or is_input then
			if type(target) ~= 'string' then
				return {'error', '(req).target must be a string'}
			end
		elseif is_value then
			-- distributions are not supported
			if tonumber(target) == nil then
				return {'error', '(req).target must be a number'}
			end
		end
		local ranges = gron['ranges']
		if type(ranges) ~= 'table' then
			return {'error', '(req).ranges invalid or missing'}
		end
		if #ranges ~= #gron - 1 then
			return {'error', '(req): number of ranges not equal to the number of branches'}
		end
		if is_data and data[target] == nil and partial then
			-- do not evaluate this (req)
			for i = 2, #gron do
				gron[i] = p.resolve_reqs(gron[i], data, partial)
			end
			return gron
		elseif is_input and data['Input:' .. target] == nil and partial then
			-- do not evaluate this (req)
			for i = 2, #gron do
				gron[i] = p.resolve_reqs(gron[i], data, partial)
			end
			return gron
		else
			local val
			if is_value then
				val = tonumber(target)
			else
				local key = target
				-- TODO: type suffix?
				if is_input then
					key = 'Input:' .. key
				end
				val = data[key] or 0
			end
			for i = 2, #gron do
				local range_i = ranges[i - 1]
				local rmin_i, rmax_i = util.parse_range(range_i)
				if rmin_i == nil or rmax_i == nil then
					return {'error', '(req): invalid ranges[' .. (i - 1) .. ']: ' .. tostring(range_i)}
				end
				if rmin_i <= val and val <= rmax_i then
					return p.resolve_reqs(gron[i], data, partial)
				end
			end
			-- All (req) branches failed to match the target value.
			return {'nop', avoid='yes'}
		end
	else
		local new_gron = {}
		for k, v in pairs(gron) do
			new_gron[k] = p.resolve_reqs(v, data, partial)
		end
		return new_gron
	end
end

-- Multiplies what is supposed to be a number by `m`.
-- TODO: handle (req)!
local function multiply_number(gron, m)
	if tonumber(gron) ~= nil then
		return tonumber(gron) * m
	end
	if type(gron) ~= 'table' then
		return {'err', 'unknown number type: ' .. type(gron)}
	end
	local gtype = gron[1]
	if gtype == 'formula' then
		local s = gron[2]
		if type(s) ~= 'string' then
			return {'err', 'empty formula'}
		end
		local tree, err = fml.parse(s)
		if err then
			return {'err', 'formula error: ' .. err}
		end
		tree = {{'*', tree, {m, 'num'}}, 'binary'}
		return {'formula', fml.encode(tree)}
	elseif gtype == 'input' then
		local i, itype = gron[2], gron['type']
		if itype == nil then
			i, itype = util.normalise_input(i)
		end
		return {'input', i, ['type']=itype, x=m}
	elseif gtype == 'err' then
		return gron
	else
		return {'err', 'unknown number type: ' .. tostring(gtype)}
	end
end

-- Resolves (formula)e before evaluation.
-- Formatting functions cannot display raw distributions.
-- `partial`: if true, only substitute known data.
function p.resolve_formulae(gron, data, partial)
	partial = partial or false
	if type(gron) ~= 'table' then
		return gron
	end
	local gtype = gron[1]
	if gtype == 'formula' then
		local s = gron[2]
		if type(s) ~= 'string' then
			return {'err', 'empty formula'}
		end
		local tree, err = fml.parse(s)
		if err then
			return {'err', 'formula error: ' .. err}
		end
		tree = fml.substitute(tree, data, not partial)
		err = fml.find_error(tree)
		if err then
			return {'err', 'formula evaluation error: ' .. tostring(err)}
		elseif tree[2] == 'num' then
			return tostring(tree[1])
		else
			return {'formula', fml.encode(tree)}
		end
	end
	local new_gron = {}
	for k, v in pairs(gron) do
		new_gron[k] = p.resolve_formulae(v, data, partial)
	end
	return new_gron
end

-- Or, at least, what was supposed to be a number.
-- It can turn out to be:
-- * (formula)
-- * (req)
-- * (input)
local function preprocess_number(gron)
	if type(gron) ~= 'table' then
		return gron
	end
	local gtype = gron[1]
	if gtype == 'formula' then
		return gron
	elseif gtype == 'req' then
		for i = 2, #gron do
			local gron_i, err_i = preprocess_number(gron[i])
			if err_i then
				return nil, err_i
			end
			gron[i] = gron_i
		end
		return gron
	elseif gtype == 'input' then
		local name = gron[2]
		if type(name) ~= 'string' then
			return nil, 'Empty (input)!'
		end
		local itype = gron['type']
		if itype == nil then
			name, itype = util.normalise_input(name)
		end
		return {'input', name, ['type']=itype, ['x']=gron['x']}
	end
	return nil, 'unknown number type: ' .. tostring(gtype)
end

-- Preprocesses an effect for further usage:
-- * Does Echo->Penny conversion.
function p.preprocess_effect(effect)
	if effect == nil then
		return nil
	end
	local new_effect = {}
	for resource, delta in pairs(effect) do
		-- TODO: use preprocess_number() first
		if resource == 'Echo' then
			if effect['Penny'] then
				new_effect['Penny'] = mw.clone(effect['Penny'])
			else
				new_effect['Penny'] = multiply_number(delta, 100)
			end
		else
			new_effect[resource] = mw.clone(delta)
		end
	end
	return new_effect
end

-- Prepares GRON for further usage:
-- * Removes branches that make no sense;
-- * Resolves (import)s (with `import_all`, late (import)s as well);
-- * Looks for some of possible errors and indicates them;
-- * Does Echo->Penny conversion.
function p.preprocess_gron(frame, gron, resource, cache, import_all)
	import_all = import_all or false
	local gtype = gron[1]
	if gtype == 'error' then
		return gron
	end
	if gtype == 'nop' then
		return gron
	end
	if gtype == 'input' then
		local name = gron[2]
		if type(name) ~= 'string' then
			return {'error', 'Empty (input)!'}
		end
		local itype = gron['type']
		if itype == nil then
			name, itype = util.normalise_input(name)
		end
		return {'input', name, ['type']=itype, ['x']=gron['x']}
	end
	if gtype == 'import' then
		local title = gron[2]
		if util.s2b(gron['late']) and not import_all then
			return gron
		end
		if cache[title] == nil then
			local gron_text = frame:callParserFunction{
				name = '#show',
				args = {title, '?Has grind definition'}
			}
			if gron_text ~= nil and gron_text ~= '' then
				cache[title] = GRON.parse(gron_text)
			else
				return {'error', '(import) cannot import: ' .. title}
			end
		end
		local imported_gron = mw.clone(cache[title])
		imported_gron = p.preprocess_gron(frame, imported_gron, resource, cache, import_all)

		local assume = gron['assume'] or {}
		if type(assume) ~= 'table' then
			return {'error', '(import).assume is invalid'}
		end
		for k, v in pairs(assume) do
			assume[k] = tonumber(v)
		end
		imported_gron = p.resolve_reqs(imported_gron, assume, true)
		imported_gron = p.resolve_formulae(imported_gron, assume, true)
		return imported_gron
	end
	if gtype == 'action' then
		local new_gron = mw.clone(gron)
		if type(new_gron['a']) == 'table' then
			new_gron['a'] = preprocess_number(new_gron['a'])
		end
		new_gron['success'] = p.preprocess_effect(new_gron['success'])
		new_gron['alt_success'] = p.preprocess_effect(new_gron['alt_success'])
		new_gron['failure'] = p.preprocess_effect(new_gron['failure'])
		new_gron['alt_failure'] = p.preprocess_effect(new_gron['alt_failure'])
		local challenge = new_gron['challenge']
		if challenge ~= nil and challenge['Echo'] ~= nil then
			-- I doubt that will ever happen outside of weird places
			-- like the Iron Republic but let's handle this case anyway.
			local data = challenge['Echo']
			if type(data) == 'table' and (data[2] == 'broad' or data[2] == 'narrow') then
				data[1] = multiply_number(data[1], 100)
			else
				data = multiply_number(data, 100)
			end
			challenge['Echo'] = nil
			challenge['Penny'] = data
		end
		return new_gron
	end
	if gtype == 'seq' then
		local new_gron = {'seq'}
		for i = 2, #gron do
			local gron_i = p.preprocess_gron(frame, gron[i], resource, cache, import_all)
			if gron_i == nil or gron_i[1] ~= 'seq' then
				table.insert(new_gron, gron_i)
			else
				for j = 2, #gron_i do
					table.insert(new_gron, gron_i[j])
				end
			end
		end
		return new_gron
	end
	if gtype == 'airs' then
		local new_gron = {'airs'}
		new_gron['range'] = gron['range']
		new_gron['target'] = gron['target']
		if type(gron['ranges']) == 'string' then
			gron['ranges'] = {gron['ranges']}
		end
		new_gron['ranges'] = gron['ranges']
		new_gron['ranges'] = {}
		local last_item = nil
		local last_range = nil
		local all_equal = true
		for i = 2, #gron do
			local item = p.preprocess_gron(frame, gron[i], resource, cache, import_all)
			local range = gron['ranges'][i - 1]
			if util.tables_equal(last_item, item) then
				-- merge ranges instead of adding a new branch, if possible
				local last_min, last_max = util.parse_range(last_range)
				local min, max = util.parse_range(range)
				if last_max ~= nil and min ~= nil and last_max + 1 == min then
					if max ~= max + 1 then
						range = last_min .. '-' .. max
					else
						range = last_min .. '-'
					end
					new_gron['ranges'][#(new_gron['ranges'])] = range
				else
					table.insert(new_gron, item)
					table.insert(new_gron['ranges'], range)
				end
			else
				table.insert(new_gron, item)
				table.insert(new_gron['ranges'], range)
			end
			if all_equal and last_item ~= nil then
				all_equal = util.tables_equal(last_item, item)
			end
			last_item = item
			last_range = range
		end
		if last_item ~= nil and all_equal then
			return last_item
		else
			return new_gron
		end
	end
	if gtype == 'req' then
		local new_gron = {'req'}
		new_gron['target'] = gron['target']
		new_gron['input'] = gron['input']
		new_gron['value'] = gron['value']
		if type(gron['ranges']) == 'string' then
			gron['ranges'] = {gron['ranges']}
		end
		new_gron['ranges'] = gron['ranges']
		if gron['target'] == 'Echo' then
			new_gron['target'] = 'Penny'
			for i, range in ipairs(new_gron['ranges']) do
				new_gron[i] = util.multiply_range(range, 100)
			end
		end
		for i = 2, #gron do
			local gron_i = p.preprocess_gron(frame, gron[i], resource, cache, import_all)
			table.insert(new_gron, gron_i)
		end
		return new_gron
	end
	if gtype == 'pswitch' then
		local new_gron = {'pswitch'}
		new_gron['target'] = gron['target']
		new_gron['action'] = p.preprocess_gron(frame, gron['action'], gron['target'], cache, import_all)
		if type(gron['ranges']) == 'string' then
			gron['ranges'] = {gron['ranges']}
		end
		new_gron['ranges'] = gron['ranges']
		if gron['target'] == 'Echo' then
			new_gron['target'] = 'Penny'
			for i, range in ipairs(new_gron['ranges']) do
				new_gron[i] = util.multiply_range(range, 100)
			end
		end
		for i = 2, #gron do
			local gron_i = p.preprocess_gron(frame, gron[i], resource, cache, import_all)
			table.insert(new_gron, gron_i)
		end
		return new_gron
	end
	if gtype == 'best' then
		local new_gron = {'best'}
		for i = 2, #gron do
			local gron_i = p.preprocess_gron(frame, gron[i], resource, cache, import_all)
			if resource == nil or is_source_of(gron_i, resource) then
				if gron_i[1] ~= 'nop' or not util.s2b(gron_i['avoid'], false) then
					table.insert(new_gron, gron_i)
				end
			end
		end
		if #new_gron == 2 then
			return new_gron[2]
		elseif #new_gron == 1 then
			return {'nop', avoid=yes}
		end
		return new_gron
	end
	if gtype == 'gate' then
		local new_gron = {'gate'}
		new_gron['target'] = gron['target']
		new_gron['reset'] = gron['reset']
		new_gron['must_succeed'] = gron['must_succeed']
		new_gron['failure_cost'] = preprocess_number(gron['failure_cost'])
		new_gron['preserve_progress'] = gron['preserve_progress']
		new_gron['optimise'] = gron['optimise']
		if gron['resolution'] ~= nil then
			new_gron['resolution'] = p.preprocess_gron(
				frame, gron['resolution'], resource, cache, import_all
			)
		end
		if type(gron['target']) ~= 'table' then
			if gron['target'] == nil then
				return {'error', '(gate): target undefined'}
			else
				return {'error', '(gate): target incorrect'}
			end
		end
		local g_resource = gron['target'][1]
		if g_resource == nil then
			return {'error', '(gate): target[1] undefined'}
		end
		if tonumber(new_gron['target'][2]) == nil then
			return {'error', '(gate): target[2] is not a number: ' .. tostring(gron['target'][2])}
		end
		if g_resource == 'Echo' then
			new_gron['target'][1] = 'Penny'
			new_gron['target'][2] = multiply_number(new_gron['target'][2], 100)
			if new_gron['failure_cost'] ~= nil then
				new_gron['failure_cost'] = multiply_number(new_gron['failure_cost'], 100)
			end
			g_resource = 'Penny'
		end
		if not util.s2b(gron['optimise'], true) then
			-- this (gate) only tracks its resource
			g_resource = resource
		end
		new_gron['action'] = p.preprocess_gron(frame, gron['action'], g_resource, cache, import_all)
		return new_gron
	end
	if gtype == 'filter' then
		local action = gron[2]
		if type(action) ~= 'table' then
			return {'error', '(filter)[2] incorrect or undefined'}
		end
		action = p.preprocess_gron(frame, action, resource, cache, import_all)
		local target = gron['target'] or {}
		if type(target) == 'string' then
			target = {target}
		end
		if type(target) ~= 'table' then
			return {'error', '(filter).target incorrect or undefined'}
		end
		local new_gron = {
			'filter',
			action,
			target=target
		}
		return new_gron
	end
	if gtype == 'sell' then
		local action = gron['action']
		if type(action) ~= 'table' then
			return {'error', '(sell).action incorrect or undefined'}
		end
		if gron['optimise'] == nil then
			action = p.preprocess_gron(frame, action, resource, cache, import_all)
		else
			-- how exactly will optimisation behave on evaluation?
			-- we cannot know at this stage, therefore assume nothing specific
			action = p.preprocess_gron(frame, action, nil, cache, import_all)
		end
		if gron['market'] ~= nil and type(gron['market']) ~= 'string' then
			return {'error', '(sell).market must be a string'}
		end
		if gron['optimise'] ~= nil and type(gron['optimise']) ~= 'string' then
			return {'error', '(sell).optimise must be undefined or a string'}
		end
		local new_gron = {
			'sell',
			action=action,
			market=gron['market'],
			optimise=gron['optimise']
		}
		for k, eff in pairs(gron) do
			if type(k) == 'string' and k ~= 'action' and k ~= 'market' and k ~= 'optimise' then
				new_gron[k] = p.preprocess_effect(eff)
			end
		end
		return new_gron
	end
	return {'error', 'unknown object of type ' .. tostring(gtype)}
end

-- Substitutes inputs.
function p.compose(gron, args)
	if type(gron) ~= 'table' then
		return gron
	end
	local gtype = gron[1]
	if gtype == 'input' then
		local key = gron[2]
		local x = gron['x']
		local val = args[key]
		if val == nil then
			return {'error', '(input): input not provided for ' .. key}
		end
		if x ~= nil then 
			return val * x
		else
			return mw.clone(val)
		end
	end
	for k, v in pairs(gron) do
		gron[k] = p.compose(v, args)
	end
	return gron
end

return p