Module:Grind/GRON

From Fallen London Wiki

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

local p = {}

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

-- == GRON parsing & encoding utilities ==

-- Escape sequence expansion.
local function unescape(initial_s)
	local s = ''
	local esc = false
	for pos = 1, #initial_s do
		local c = initial_s:sub(pos, pos)
		if esc then
			if c == '"' or c == '\\' then
				s = s .. c
			else
				s = s .. '\\' .. c
			end
			esc = false
		else
			if c == '\\' then
				esc = true
			else
				s = s .. c
			end
		end
	end
	if esc then
		s = s .. '\\'
	end
	return s
end

-- GRON format parser.
-- Returns Lua table representation of the object.
-- Returns `nil` if the string does not encode a valid GRON object.
function p.parse(s)
	local gron = {}
	local pos = 1 -- string position
	local valc = 0 -- numbered values

	pos = util.skip_whitespaces(s, pos)

	-- expect (
	if s:sub(pos, pos) ~= '(' then
		return nil
	end
	local gron_end = util.find_match(s, pos)
	if gron_end == nil then
		return nil
	end
	pos = pos + 1

	pos = util.skip_whitespaces(s, pos)

	-- parse entries
	while pos < gron_end do
		pos = util.skip_whitespaces(s, pos)

		local key = nil
		local val = nil
		local val_tuple = nil

		-- parse the key
		local current_char = s:sub(pos, pos)
		if current_char == '"' then
			local key_end = util.find_match(s, pos)
			key = s:sub(pos + 1, key_end - 1)
			key = unescape(key)
			pos = key_end + 1
			pos = util.skip_whitespaces(s, pos)
		elseif current_char == '(' then
			local key_end = util.find_match(s, pos)
			key = p.parse(s:sub(pos, key_end))
			pos = key_end + 1
			pos = util.skip_whitespaces(s, pos)
		else
			key = ''
			while current_char ~= ':' and current_char ~= ';' and current_char ~= ')' do
				key = key .. current_char
				pos, current_char = util.advance(s, pos)
			end
			key = util.strip_whitespaces(key)
		end

		local correct = false
		current_char = s:sub(pos, pos)
		if current_char == ')' then
			-- the last entry is a numbered value
			valc = valc + 1
			gron[valc] = key
			break
		end
		if current_char == ';' then
			-- the entry is a numbered value
			correct = true
			valc = valc + 1
			gron[valc] = key
			pos = pos + 1
		end
		if current_char == ':' then
			-- the entry is a key-values pair
			correct = true
			pos = pos + 1
			
			pos = util.skip_whitespaces(s, pos)

			val = ''
			val_tuple = nil
			current_char = s:sub(pos, pos)
			while current_char ~= ';' and current_char ~= ')' do
				local skip = false
				if current_char == ',' then
					if val_tuple == nil then
						val_tuple = {}
					end
					table.insert(val_tuple, val)
					val = ''
					pos = pos + 1
					pos = util.skip_whitespaces(s, pos)
					current_char = s:sub(pos, pos)
					skip = true
				end
				if not skip and current_char == '(' then
					local val_gron_end = util.find_match(s, pos)
					local val_gron = s:sub(pos, val_gron_end)
					val = p.parse(val_gron)
					pos = val_gron_end + 1
					pos = util.skip_whitespaces(s, pos)
					current_char = s:sub(pos, pos)
					if current_char ~= ',' and current_char ~= ';' and current_char ~= ')' then
						return nil
					end
					skip = true
				end
				if not skip and current_char == '"' then
					local val_end = util.find_match(s, pos)
					val = s:sub(pos + 1, val_end - 1)
					val = unescape(val)
					pos = val_end + 1
					pos = util.skip_whitespaces(s, pos)
					current_char = s:sub(pos, pos)
					if current_char ~= ',' and current_char ~= ';' and current_char ~= ')' then
						return nil
					end
					skip = true
				end
				if not skip then
					while current_char ~= ',' and current_char ~= ';' and current_char ~= ')' do
						val = val .. current_char
						pos = pos + 1
						current_char = s:sub(pos, pos)
					end
					val = util.strip_whitespaces(val)
				end
			end
			-- the remaining val
			if val ~= nil and val_tuple ~= nil then
				table.insert(val_tuple, val)
				val = nil
			end
			if val_tuple ~= nil then
				gron[key] = val_tuple
			else
				gron[key] = val
			end
			if current_char == ')' then
				break
			end
			pos = pos + 1
		end
		if not correct then
			return nil
		end
	end
	return gron
end

-- Encodes the string for string GRON representation.
-- Escape sequences and quotation marks are added where necessary.
function p.encode_string(str, only_contents)
	only_contents = only_contents or false
	if type(str) ~= 'string' then
		return ''
	end
	if str == str:match('[%w%-_]*') then
		return str
	end
	local s = ''
	if not only_contents then
		s = s .. '"'
	end
	for i = 1, #str do
		local current_char = str:sub(i, i)
		if current_char == '"' then
			s = s .. '\\"'
		elseif current_char == '\\' then
			s = s .. '\\\\'
		else
			s = s .. current_char
		end
	end
	if not only_contents then
		s = s .. '"'
	end
	return s
end

-- Encodes GRON in string format.
-- The argument is the Lua table representation.
function p.encode(gron)
	local s = '('
	local first = true
	for k, v in pairs(gron) do
		if not first then
			s = s .. ';'
		end
		first = false
		if type(k) == 'number' then
			if type(v) == 'table' then
				s = s .. p.encode(v)
			elseif type(v) == 'string' then
				s = s .. p.encode_string(v)
			elseif type(v) == 'number' then
				s = s .. v
			end
		elseif type(k) == 'string' then
			s = s .. p.encode_string(k) .. ':'
			if type(v) == 'table' then
				s = s .. p.encode(v)
			elseif type(v) == 'string' then
				s = s .. p.encode_string(v)
			elseif type(v) == 'number' then
				s = s .. v
			end
		end
	end
	return s .. ')'
end

-- Returns a table of declared GRON inputs.
function p.inputs(gron)
	if type(gron) ~= 'table' then
		return {}
	end
	local gtype = gron[1]
	local inputs = {}
	for _, v in pairs(gron) do
		for k, t in pairs(p.inputs(v)) do
			inputs[k] = t
		end
	end
	if gtype == 'input' then
		inputs[gron[2]] = gron['type'] or 'unknown'
	end
	if gtype == 'formula' then
		local tree, err = fml.parse(gron[2])
		if not err then
			local vars = fml.variables(tree)
			for v, _ in pairs(vars) do
				if v:sub(1, 6) == 'Input:' then
					local i, itype = v:sub(7), nil
					i, itype = util.normalise_input(i)
					if inputs[i] == 'unknown' and itype ~= 'unknown' then
						-- now it is known
						inputs[i] = itype
					else
						inputs[i] = inputs[i] or itype
					end
				end
			end
		end
	end
	return inputs
end

-- Returns a table of qualities used in GRON formulae.
local function formula_qualities(gron, qualities)
	qualities = qualities or {}
	if type(gron) ~= 'table' then
		return qualities
	end
	local gtype = gron[1]
	if gtype == 'formula' then
		local tree, err = fml.parse(gron[2])
		if not err then
			local vars = fml.variables(tree)
			for v, _ in pairs(vars) do
				if v:sub(1, 6) ~= 'Input:' then
					qualities[v] = qualities[v] or common.identify(v)
				end
			end
		end
	end
	for k, v in pairs(gron) do
		qualities = formula_qualities(v, qualities)
	end
	return qualities
end

-- Returns a table of possible GRON qualities used in challenges and requirements.
local function gron_qualities(gron, qualities)
	qualities = qualities or {}
	if type(gron) ~= 'table' then
		return qualities
	end
	local gtype = gron[1]
	if gtype == 'action' then
		local challenge = gron['challenge']
		if type(challenge) == 'table' then
			for quality, data in pairs(challenge) do
				qualities[quality] = common.identify(quality)
			end
		end
	end
	if gtype == 'req' then
		local quality = gron['target']
		if type(quality) == 'string' then
			local already_unknown = qualities[quality] == 'unknown'
			qualities[quality] = common.identify(quality)
			local ranges = gron['ranges']
			if not already_unknown and qualities[quality] == 'unknown' and type(ranges) == 'table' then
				local only_binary = true
				for _, range in ipairs(ranges) do
					local rmin, rmax = util.parse_range(range)
					if rmin ~= nil and rmax ~= nil then
						only_binary = only_binary and (rmin == 0 or rmin == 1)
						only_binary = only_binary and (rmax == 0 or rmax == 1 or rmax == 1/0)
					end
				end
				if only_binary then
					qualities[quality] = 'binary'
				end
			end
		end
	end
	for _, v in pairs(gron) do
		qualities = gron_qualities(v, qualities)
	end
	
	return qualities
end

-- Returns a table of qualities used in challenges, requirements, formulae.
function p.qualities(gron)
	local qualities = gron_qualities(gron)
	qualities = formula_qualities(gron, qualities)
	return qualities
end

-- Returns a table of possible effect outputs (positive or negative).
local function effect_outputs(effect, blacklist)
	if type(effect) ~= 'table' or effect[1] ~= nil then
		return {}
	end
	local outputs = {}
	for resource, _ in pairs(effect) do
		if blacklist[resource] == nil then
			outputs[resource] = true
		end
	end
	return outputs
end

-- Returns a table of possible GRON outputs (positive or negative).
function p.outputs(gron, outputs, blacklist)
	outputs = outputs or {}
	blacklist = blacklist or {}
	if type(gron) ~= 'table' then
		return outputs
	end
	local gtype = gron[1]
	if gtype == 'action' then
		local alt_success_p = gron['alt_success_p'] or '0'
		local alt_failure_p = gron['alt_failure_p'] or '0'
		alt_success_p = tonumber(alt_success_p) or 0
		alt_failure_p = tonumber(alt_failure_p) or 0
		
		for resource, _ in pairs(effect_outputs(gron['success'], blacklist)) do
			outputs[resource] = true
		end
		for resource, _ in pairs(effect_outputs(gron['failure'], blacklist)) do
			outputs[resource] = true
		end
		if alt_success_p > 0 then
			for resource, _ in pairs(effect_outputs(gron['alt_success'], blacklist)) do
				outputs[resource] = true
			end
		end
		if alt_failure_p > 0 then
			for resource, _ in pairs(effect_outputs(gron['alt_failure'], blacklist)) do
				outputs[resource] = true
			end
		end
	end
	if gtype == 'seq' or gtype == 'airs' or gtype == 'req' or gtype == 'best' then
		for i = 2, #gron do
			outputs = p.outputs(gron[i], outputs, blacklist)
		end
	end
	if gtype == 'pswitch' then
		local action_blacklist = mw.clone(blacklist)
		local target = gron['target']
		if type(target) == 'string' then
			action_blacklist[target] = true
		end
		outputs = p.outputs(gron['action'], outputs, action_blacklist)
		for i = 2, #gron do
			outputs = p.outputs(gron[i], outputs, blacklist)
		end
	end
	if gtype == 'gate' then
		local action_blacklist = mw.clone(blacklist)
		local target = gron['target']
		if type(target) == 'table' and type(target[1]) == 'string' then
			if not util.s2b(gron['preserve_progress'], false) then
				action_blacklist[target[1]] = true
			end
		end
		outputs = p.outputs(gron['action'], outputs, action_blacklist)
		outputs = p.outputs(gron['resolution'], outputs, blacklist)
	end
	if gtype == 'filter' then
		local target = gron['target'] or {}
		if type(target) == 'string' then
			target = {target}
		end
		local target_set = {}
		if type(target) == 'table' then
			for _, resource in ipairs(target) do
				target_set[resource] = true
			end
		end
		local action = gron[2]
		local a_outputs = p.outputs(action, {}, blacklist)
		for resource, _ in pairs(a_outputs) do
			-- custom antiresources are to be added to (filter).target manually
			if target_set[resource] or common.identify(resource) == 'menace' then
				outputs[resource] = true
			end
		end
	end
	if gtype == 'sell' then
		local action = gron['action']
		local action_blacklist = mw.clone(blacklist)
		for sell_resource, sell_effect in pairs(gron) do
			if type(sell_resource) == 'string' 
					and sell_resource ~= 'action' 
					and sell_resource ~= 'optimise' 
					and sell_resource ~= 'market' then
				-- Alas, we cannot filter out nonsense because the exact optimised action
				-- will only be known during actual optimisation.
				for resource, _ in pairs(effect_outputs(sell_effect, blacklist)) do
					outputs[resource] = true
				end
				action_blacklist[sell_resource] = true
			end
		end
		outputs = p.outputs(action, outputs, action_blacklist)
	end
	return outputs
end

return p