Module:Grind/fmt

From Fallen London Wiki

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

local p = {}

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

local OL, _OL = html.OL, html._OL
local UL, _UL = html.UL, html._UL
local LI, _LI = html.LI, html._LI

-- == GRON human-readable formatting ==

-- Checks whether the page exists.
-- Assumes the page is for a quality or an item.
-- Results are cached and reused.
function p.page_exists(frame, cache, name)
	if frame.callParserFunction == nil then
		return false
	end
	if cache.exists == nil then
		cache.exists = {}
	end
	if cache.exists[name] == nil then
		-- much faster than querying whether the page exists via traditional MediaWiki methods
		local game_type = frame:callParserFunction{
			name = '#show',
			args = {name, '?Has Game Type'}
		}
		cache.exists[name] = (game_type ~= nil) and (game_type ~= '')
	end
	return cache.exists[name]
end

-- Checks whether `name` is a pyramidal quality.
-- Results are cached and reused.
local function is_pyramidal(frame, cache, name)
	if frame.callParserFunction == nil then
		return false
	end
	if cache.pyramidal == nil then
		cache.pyramidal = {}
	end
	if cache.pyramidal[name] == nil then
		local increase_type = frame:callParserFunction{
			name = '#show',
			args = {name, '?Increase Type'}
		}
		cache.pyramidal[name] = increase_type == 'Pyramidal'
	end
	return cache.pyramidal[name]
end

-- Evaluates either:
-- * {{IL|name}} or
-- * {{IL|name|amount}}.
-- Results are cached and reused.
function p.get_il(frame, cache, name, amount)
	if not p.page_exists(frame, cache, name) then
		if amount ~= nil then
			return amount .. ' ' .. name
		else
			return name
		end
	end
	if amount ~= nil then
		if cache.il_amount == nil then
			cache.il_amount = {}
		end
		if cache.il_amount[name] == nil then
			cache.il_amount[name] = {}
		end
		if cache.il_amount[name][amount] == nil then
			cache.il_amount[name][amount] = frame:expandTemplate{
				title = 'IL',
				args = {name, amount}
			}
		end
		return cache.il_amount[name][amount]
	else
		if cache.il == nil then
			cache.il = {}
		end
		if cache.il[name] == nil then
			cache.il[name] = frame:expandTemplate{
				title = 'IL',
				args = {name}
			}
		end
		return cache.il[name]
	end
end

-- Evaluates {{IL|name|Appearance=Airs}}
local function get_airs_il(frame, cache, name)
	if cache.il_airs == nil then
		cache.il_airs = {}
	end
	if cache.il_airs[name] == nil then
		cache.il_airs[name] = frame:expandTemplate{
			title = 'IL',
			args = {name, Appearance='Airs'}
		}
	end
	return cache.il_airs[name]
end

-- Returns: the string; whether the last layer has binary +/-.
local function format_formula_tree(frame, cache, tree)
	local tok_data, tok_type = unpack(tree)
	if tok_type == 'num' then
		return tostring(tok_data), false
	elseif tok_type == 'var' then
		local name = tok_data
		if name:sub(1, 6) == 'Input:' then
			name = name:sub(7)
			local _
			name, _ = util.normalise_input(name)
			return '{' .. name .. '}', false
		else
			return p.get_il(frame, cache, tok_data), false
		end
	elseif tok_type == 'unary' then
		local op, inner = unpack(tok_data)
		local inner_str, inner_esc = format_formula_tree(frame, cache, inner)
		if inner_esc then
			return op .. '(' .. inner_str .. ')', false
		else
			return op .. inner_str, false
		end
	elseif tok_type == 'binary' then
		local op, a, b = unpack(tok_data)
		local a_str, a_esc = format_formula_tree(frame, cache, a)
		local b_str, b_esc = format_formula_tree(frame, cache, b)
		local esc = op:match('[+-]') == op
		local s = ''
		if op:match('[+-]') == op then
			a_esc = false
			b_esc = false
		end
		if a_esc then
			s = s .. '(' .. a_str .. ')'
		else
			s = s .. a_str
		end
		s = s .. ' ' .. op .. ' '
		if b_esc then
			s = s .. '(' .. b_str .. ')'
		else
			s = s .. b_str
		end
		return s, esc
	elseif tok_type == 'func_call' then
		local func = tok_data[1]
		if func == 'random.range' and #tok_data == 3 then
			local a = tok_data[2]
			local b = tok_data[3]
			local a_str, a_esc = format_formula_tree(frame, cache, a)
			local b_str, b_esc = format_formula_tree(frame, cache, b)
			local s = '['
			if a_esc then
				s = s .. '(' .. a_str .. ')'
			else
				s = s .. a_str
			end
			s = s .. ' to '
			if b_esc then
				s = s .. '(' .. b_str .. ')'
			else
				s = s .. b_str
			end
			s = s .. ']'
			return s, false
		else
			local s = func .. '('
			for i = 2, #tok_data do
				if i > 2 then
					s = s .. ', '
				end
				local arg = tok_data[i]
				local arg_str, arg_esc = format_formula_tree(frame, cache, arg)
				s = s .. arg_str
			end
			s = s .. ')'
			return s, false
		end
	else
		return '(unknown token of type ' .. tok_type .. ')', false
	end
end

-- Formats a formula.
local function format_formula(frame, cache, s)
	local tree, err = fml.parse(s)
	if err then
		return html.span('(formula error: ' .. err .. ')', html.red, true)
	end
	local s = format_formula_tree(frame, cache, tree)
	return html.span(s, html.orange)
end

-- Formats something that is supposed to be a string.
local function format_string(frame, cache, gron)
	if type(gron) == 'string' then
		return gron
	end
	if type(gron) ~= 'table' then
		return html.span('(invalid string)', html.red, true)
	end
	local gtype = gron[1]
	if type(gtype) ~= 'string' then
		return html.span('(invalid string: ' .. GRON.encode(gron) .. ')', html.red, true)
	end
	if gtype == 'input' then
		if type(gron[2]) == 'string' then
			return html.span('(input required: ' .. gron[2] .. ')', html.orange)
		else
			return html.span('(undefined input)', html.red, true)
		end
	elseif gtype == 'req' then
		local s = '(depending on '
		local target = gron['target']
		local ranges = gron['ranges']
		
		if type(target) ~= 'string' then
			return html.span('(invalid target in (req))', html.red, true)
		end
		s = s .. p.get_il(frame, cache, target) .. ': '
		if type(ranges) ~= 'table' then
			return html.span('(invalid ranges in (req))', html.red, true)
		end
		for i = 2, #gron do
			if i > 2 then
				s = s .. '; '
			end
			local item = gron[i]
			local range_i = ranges[i - 1]
			s = s .. 'with ' .. tostring(range_i) .. ': ' .. format_string(frame, cache, item)
		end
		s = s .. ')'
		return html.span(s, html.orange)
	else
		return html.span('(invalid value of type ' .. gtype .. ')', html.red, true)
	end
end

-- Formats something that is supposed to be a number.
local function format_number(frame, cache, gron)
	if type(tonumber(gron)) == 'number' then
		return util.f2s(tonumber(gron))
	end
	if type(gron) ~= 'table' then
		return html.span('(invalid number)', html.red, true)
	end
	local gtype = gron[1]
	if type(gtype) ~= 'string' then
		return html.span('(invalid number: ' .. GRON.encode(gron) .. ')', html.red, true)
	end
	if gtype == 'input' then
		if type(gron[2]) == 'string' then
			local x_string = ''
			if type(tonumber(gron['x'])) == 'number' and tonumber(gron['x']) ~= 1 then
				x_string = '*' .. tostring(gron['x'])
			end
			return html.span('(input required: ' .. gron[2] .. ')' .. x_string, html.orange)
		else
			return html.span('(undefined input)', html.red, true)
		end
	elseif gtype == 'req' then
		local s = '(depending on '
		local target = gron['target']
		local ranges = gron['ranges']
		
		if type(target) ~= 'string' then
			return html.span('(invalid target in (req))', html.red, true)
		end
		s = s .. p.get_il(frame, cache, target) .. ': '
		if type(ranges) ~= 'table' then
			return html.span('(invalid ranges in (req))', html.red, true)
		end
		for i = 2, #gron do
			if i > 2 then
				s = s .. '; '
			end
			local item = gron[i]
			local range_i = ranges[i - 1]
			s = s .. 'with ' .. tostring(range_i) .. ': ' .. format_number(frame, cache, item)
		end
		s = s .. ')'
		return html.span(s, html.orange)
	elseif gtype == 'formula' then
		local s = gron[2]
		if type(s) ~= 'string' then
			return html.span('(empty formula)', html.red, true)
		end
		return format_formula(frame, cache, s)
	elseif gtype == 'err' then
		return html.span('(error: ' .. tostring(gron[2]) .. ')', html.red, true)
	else
		return html.span('(invalid value of type ' .. gtype .. ')', html.red, true)
	end
end

-- Formats action effect in a human-readable format.
local function format_action_effect(frame, cache, effect, a)
	if type(effect) ~= 'table' then
		return html.span('not a table:' .. tostring(effect), html.red, true)
	end
	local s = ''
	local first = true
	for resource, delta in pairs(effect) do
		if not first then
			s = s .. ', '
		else
			first = false
		end
		local resource, delta = resource, delta
		local delta_formatted = format_number(frame, cache, delta)
		if is_pyramidal(frame, cache, resource) then
			delta_formatted = delta_formatted .. ' CP'
		else
			delta_formatted = delta_formatted .. ' x'
		end
		if delta_formatted:sub(1, 1) ~= '-' and type(delta) ~= 'table' then
			delta_formatted = '+' .. delta_formatted
		end
		s = s .. p.get_il(frame, cache, resource, delta_formatted)
	end
	if tonumber(a) ~= nil then
		if not first then
			s = s .. ', '
		end
		s = s .. util.f2s(tonumber(a)) .. ' action'
		if tonumber(a) ~= 1 then
			s = s .. 's'
		end
	end
	s = s .. '.'
	return s
end

-- Formats GRON in a human-readable format.
function p.format_gron(frame, cache, gron)
	if type(gron) ~= 'table' then
		return 'Not a table: ' .. tostring(gron)
	end
	local gtype = gron[1]
	local s = ''
	if gtype == 'error' then
		if type(gron[2]) == 'string' then
			s = html.span('Error! ' .. gron[2] .. '.', html.red, true)
		else
			s = html.span('Error! No error information available.', html.red)
		end
	end
	if gtype == 'nop' then
		s = 'Do nothing.'
	end
	if gtype == 'input' then
		s = html.span('Input required: ' .. gron[2], html.orange, true)
	end
	if gtype == 'action' then
		local title = gron['title']
		local link = gron['link']
		local challenge = gron['challenge']
		local a = gron['a'] or 1
		local comment = gron['comment']
		local success = gron['success'] or {}
		local failure = gron['failure'] or {}
		local alt_success = gron['alt_success'] or {}
		local alt_failure = gron['alt_failure'] or {}
		local alt_success_p = gron['alt_success_p'] or '0'
		local alt_failure_p = gron['alt_failure_p'] or '0'
		local expected = gron['expected'] or {}
		local a_expected = gron['a_expected'] or a
		local prob = gron['p']
		
		if title then
			link = link or (p.page_exists(frame, cache, title) and title)
			if link then
				s = s .. '[[' .. link .. '|' .. title .. ']]'
			else
				s = s .. title
			end
		end
		if challenge then
			s = s .. ' ('
			local first = true
			for quality, info in pairs(challenge) do
				if not first then
					s = s .. ', '
				end
				first = false
				local level, ctype
				if type(info) == 'string' or type(info) == 'number' then
					level, ctype, step = info, 'broad', nil
				elseif type(info) == 'table' then
					if type(info[1]) == 'string' and tonumber(info[1]) == nil then
						level = format_number(frame, cache, info)
						ctype, step = 'broad', nil
					else
						level = format_number(frame, cache, info[1])
						ctype = info[2]
						step = info[3] or '10'
					end
				else
					level = html.span('(invalid challenge: ' .. tostring(info) .. ')', html.red)
					ctype = 'broad'
					step = '10'
				end
				local quality_il = p.get_il(frame, cache, quality)
				s = s .. quality_il .. ' ' .. level
				if quality == 'Luck' then
					s = s .. '%'
				elseif ctype ~= 'broad' then
					s = s .. ' (narrow'
					if step ~= '10' then
						s = s .. ', ' .. step .. '% step'
					end
					s = s .. ')'
				end
			end
			s = s .. ' challenge'
			if prob ~= nil then
				s = s .. string.format(': %.2f', prob * 100) .. '%'
			end
			s = s .. ')'
		end
		if s ~= '' and (s:sub(-1, -1) == ')' or (title or ''):sub(-1, -1) ~= '.') then
			s = s .. '. '
		end
		if a ~= 1 and a ~= '1' then
			s = s .. 'Action cost: ' .. format_number(frame, cache, a) .. '. '
		end
		if comment ~= nil then
			s = s .. format_string(frame, cache, comment)
		end
		local p_alt_s = tonumber(alt_success_p)
		if p_alt_s ~= nil then
			p_alt_s = p_alt_s / 100
		end
		local p_alt_f = tonumber(alt_failure_p)
		if p_alt_f ~= nil then
			p_alt_f = p_alt_f / 100
		end
		local need_success = (prob == nil) or (prob > 0)
		local need_failure = (prob == nil) or (prob < 1)
		local need_alt_success = p_alt_s > 0
		local need_alt_failure = p_alt_f > 0
		local need_expected = util.b2n(need_success) + util.b2n(need_failure) + util.b2n(need_alt_success) + util.b2n(need_alt_failure) > 1
		local has_effect = (next(success) or next(alt_success) or next(failure) or next(alt_failure)) ~= nil
		if has_effect then
			s = s .. UL
		end
		if need_success and next(success) ~= nil then
			s = s .. LI .. 'Success: '
			s = s .. format_action_effect(frame, cache, success)
			s = s .. _LI
		end
		if need_success and need_alt_success and next(alt_success) ~= nil then
			s = s .. LI .. 'Alternative success (' .. string.format('%.2f', p_alt_s * 100) .. '%): '
			s = s .. format_action_effect(frame, cache, alt_success)
			s = s .. _LI
		end
		if need_failure and next(failure) ~= nil then
			s = s .. LI .. 'Failure: '
			s = s .. format_action_effect(frame, cache, failure)
			s = s .. _LI
		end
		if need_failure and need_alt_failure and next(alt_failure) ~= nil then
			s = s .. LI .. 'Alternative failure (' .. string.format('%.2f', p_alt_f * 100) .. '%): '
			s = s .. format_action_effect(frame, cache, alt_failure)
			s = s .. _LI
		end
		if need_expected and next(expected) ~= nil then
			s = s .. LI .. 'Expected action effect: '
			if tonumber(a_expected) ~= tonumber(a) then
				s = s .. format_action_effect(frame, cache, expected, a_expected)
			else
				s = s .. format_action_effect(frame, cache, expected)
			end
			s = s .. _LI
		end
		if has_effect then
			s = s .. _UL
		end
	end
	if gtype == 'seq' then
		s = s .. OL
		for i = 2, #gron do
			local item = gron[i]
			s = s .. LI .. p.format_gron(frame, cache, item) .. _LI
		end
		s = s .. _OL
	end
	if gtype == 'airs' then
		local range = gron['range'] or '1-100'
		local ranges = gron['ranges']
		local target = gron['target']
		
		local target_il
		if target then
			target_il = get_airs_il(frame, cache, target)
			s = s .. 'Depending on '
		else
			target_il = 'Airs'
		end
		
		s = s .. target_il .. ' (' .. range .. '):' .. UL
		for i = 2, #gron do
			local item = gron[i]
			local range_i = ranges[i - 1]
			s = s .. LI
			if target then
				s = s .. target_il .. ' '
			end
			s = s .. tostring(range_i) .. ': ' .. p.format_gron(frame, cache, item)
			s = s .. _LI
		end
		s = s .. _UL
	end
	if gtype == 'req' then
		local req = gron['target']
		local ranges = gron['ranges']
		
		s = s .. 'Depending on '
		s = s .. p.get_il(frame, cache, req) .. ':' .. UL
		for i = 2, #gron do
			local item = gron[i]
			local range_i = ranges[i - 1]
			s = s .. LI .. range_i .. ': ' .. p.format_gron(frame, cache, item) .. _LI
		end
		s = s .. _UL
	end
	if gtype == 'pswitch' then
		local target = gron['target']
		local ranges = gron['ranges']
		local action = gron['action']
		local prob = gron['p']
		
		s = s .. 'Do the following to collect '
		s = s .. p.get_il(frame, cache, target)
		s = s .. ':' .. UL .. LI .. p.format_gron(frame, cache, action) .. _LI .. _UL
		
		s = s .. 'Then, depending on '
		s = s .. p.get_il(frame, cache, target)
		s = s .. ' progress, do the following:' .. UL
		for i = 2, #gron do
			local item = gron[i]
			local range_i = ranges[i - 1]
			s = s .. LI .. tostring(range_i)
			if prob ~= nil then
				local p_i = prob[i - 1]
				s = s .. ' (' .. string.format('%.2f', p_i * 100) .. '%)'
			end
			s = s .. ': ' .. p.format_gron(frame, cache, item) .. _LI
		end
		s = s .. _UL
	end
	if gtype == 'best' then
		s = s .. 'Choose the best option:' .. UL
		for i = 2, #gron do
			local item = gron[i]
			s = s .. LI .. p.format_gron(frame, cache, item) .. _LI
		end
		s = s .. _UL
	end
	if gtype == 'gate' then
		local target = gron['target']
		local action = gron['action']
		local resolution = gron['resolution']
		local times = gron['times']
		local reset = gron['reset']
		local must_succeed = gron['must_succeed']
		local failure_cost = tonumber(gron['failure_cost']) or 0
		local attempts = tonumber(gron['attempts']) or 1
		
		local resource, level, resource_il
		if type(target) ~= 'table' then
			resource_il = html.span('(invalid target)', html.red, true)
			level = nil
		else
			resource, level = target[1], target[2]
			resource_il = p.get_il(frame, cache, resource)
		end
		
		s = s .. 'Collect ' .. resource_il .. ' ' .. format_number(frame, cache, level)
		if is_pyramidal(frame, cache, resource) then
			s = s .. ' CP'
		end
		if util.s2b(must_succeed) and failure_cost ~= 0 and failure_cost ~= '0' then
			s = s .. ' (failure later will remove '
			s = s .. resource_il .. ' ' .. format_number(frame, cache, failure_cost) .. ')'
		end
		if util.s2b(reset) then
			s = s .. ' (will be reset)'
		end
		s = s .. '. '
		s = s .. 'For that, do the following'
		if times == 1 then
			s = s .. ' once'
		elseif times == 1/0 then
			s = s .. ' again and again and again'
		elseif times ~= nil then
			s = s .. ' ' .. util.f2s(times) .. ' times'
		end
		s = s .. ':' .. UL
		if type(action) == 'table' and action[1] == 'seq' then
			s = s .. p.format_gron(frame, cache, action)
		else
			s = s .. LI .. p.format_gron(frame, cache, action) .. _LI
		end
		s = s .. _UL
		if resolution ~= nil then
			s = s .. 'Then, do the following'
			if attempts > 1 then
				s = s .. ' (expect ' .. util.f2s(attempts) .. ' attempts)'
			end
			s = s .. ':' .. UL
			if type(resolution) == 'table' and resolution[1] == 'seq' then
				s = s .. p.format_gron(frame, cache, resolution)
			else
				s = s .. LI .. p.format_gron(frame, cache, resolution) .. _LI
			end
			s = s .. _UL
		end
	end
	if gtype == 'filter' then
		local action = gron[2]
		s = s .. p.format_gron(frame, cache, action)
	end
	if gtype == 'sell' then
		local action = gron['action']
		local market = gron['market']
		
		-- TODO: check for more possible unexpected non-effect entries
		
		s = s .. 'Sell resources'
		if type(market) == 'string' then
			s = s .. ' at ' .. market
		end
		s = s .. ':' .. UL
		for sell_resource, sell_effect in pairs(gron) do
			if type(sell_resource) == 'string'
					and sell_resource ~= 'action'
					and sell_resource ~= 'market'
					and sell_resource ~= 'optimise' then
				s = s .. LI .. p.get_il(frame, cache, sell_resource)
				s = s .. ': ' .. format_action_effect(frame, cache, sell_effect)
				s = s .. _LI
			end
		end
		s = s .. _UL
		s = s .. 'For that, do the following:' .. UL
		if type(action) == 'table' and action[1] == 'seq' then
			s = s .. p.format_gron(frame, cache, action)
		else
			s = s .. LI .. p.format_gron(frame, cache, action) .. _LI
		end
		s = s .. _UL
	end
	return s
end

-- Formats the expected effect in a human-readable format.
function p.format_expected_effect(frame, expected_effect, cache)
	local s = util.f2s(expected_effect.a) .. ' action'
	if tonumber(expected_effect.a) ~= 1 then
		s = s .. 's'
	end
	for resource, delta in pairs(expected_effect.effect) do
		s = s .. ', '
		local resource, delta = resource, delta
		local delta_formatted = util.f2s(delta)
		if is_pyramidal(frame, cache, resource) then
			delta_formatted = delta_formatted .. ' CP'
		else
			delta_formatted = delta_formatted .. ' x'
		end
		if delta_formatted:sub(1, 1) ~= '-' then
			delta_formatted = '+' .. delta_formatted
		end
		s = s .. p.get_il(frame, cache, resource, delta_formatted)
	end
	return s
end

return p