Module:Tombs of Amascut loot

Module documentation
This documentation is transcluded from Module:Tombs of Amascut loot/doc. [edit] [history] [purge]
Module:Tombs of Amascut loot's function main is invoked by Calculator:Tombs of Amascut loot.
Module:Tombs of Amascut loot requires Module:Exchange.
Module:Tombs of Amascut loot requires Module:Less Stupid Coins.
Module:Tombs of Amascut loot requires Module:Tables.

local gePrice = require('Module:Exchange')._price
local coins = require('Module:Less Stupid Coins')._amount
local tables = require('Module:Tables')
local lang = mw.language.getContentLanguage()

local p = {}

local PATH_INVOCATIONS = {None = 0, Pathseeker = 1, Pathfinder = 2, Pathmaster = 3}
local NORMAL_LOOT = {
	{item = "Coins", divisor = 1},
	{item = "Death rune", divisor = 20},
	{item = "Soul rune", divisor = 40},
	{item = "Gold ore", divisor = 90},
	{item = "Dragon dart tip", divisor = 100},
	{item = "Mahogany logs", divisor = 180},
	{item = "Sapphire", divisor = 200},
	{item = "Emerald", divisor = 250},
	{item = "Gold bar", divisor = 250},
	{item = "Potato cactus", divisor = 250},
	{item = "Raw shark", divisor = 250},
	{item = "Ruby", divisor = 300},
	{item = "Diamond", divisor = 400},
	{item = "Raw manta ray", divisor = 450},
	{item = "Cactus spine", divisor = 600},
	{item = "Dragonstone", divisor = 600},
	{item = "Battlestaff", divisor = 1100},
	{item = "Coconut milk", divisor = 1100},
	{item = "Lily of the sands", divisor = 1100},
	{item = "Toadflax seed", divisor = 1400},
	{item = "Ranarr seed", divisor = 1800},
	{item = "Torstol seed", divisor = 2200},
	{item = "Snapdragon seed", divisor = 2200},
	{item = "Dragon med helm", divisor = 4000},
	{item = "Magic seed", divisor = 6500},
	{item = "Blood essence", divisor = 7500},
	{item = "Cache of runes", divisor = 999999}
}

local PURPLES = {
	{item = "Tumeken's shadow (uncharged)", weight = 1, level = 150},
	{item = "Masori mask", weight = 2, level = 150},
	{item = "Masori body", weight = 2, level = 150},
	{item = "Masori chaps", weight = 2, level = 150},
	{item = "Elidinis' ward", weight = 3, level = 150},
	{item = "Osmumten's fang", weight = 7, level = 50},
	{item = "Lightbearer", weight = 7, level = 50}
}

local TEAM_SIZE_SCALE = {
	[1] = 1.0,
	[2] = 1.9,
	[3] = 2.8,
	[4] = 3.4,
	[5] = 4.0,
	[6] = 4.6,
	[7] = 5.2,
	[8] = 5.8
}

function p.main(frame)
	local frame = frame or {}
	local args = frame.args or {}
	
	local raid_level = tonumber(args.raid_level) or 550
	local team_size = tonumber(args.team_size) or 8
	local path_invocation = PATH_INVOCATIONS[args.path_invocation or 'None'] or 0
	local walk_the_path = args.walk_the_path == 'yes'
	local leagues_harder_mode = args.leagues_harder_mode == 'yes' 
	
	out = p.calculator(raid_level, team_size, path_invocation, walk_the_path, leagues_harder_mode) 
	mw.log(out)
	return out
end

function scale_hp(base, raid_level, team_size, path_level)

	local path_multiplier = 1.0
	if path_level > 0 then
		-- this isn't perfectly accurate because 0, 8, 13, 18... isn't linear, but it's close enough
		path_multiplier = 1.03 + path_level * 0.05
	end
	-- this seems to round to the nearest 5 or 10, but the exact rounding semantics aren't clear (and we're already doing some averaging with WTP)
	return math.floor(base * (1 + raid_level / 250) * TEAM_SIZE_SCALE[team_size] * path_multiplier)
end

function p.estimate_points(raid_level, team_size, path_invocation, walk_the_path)
	-- to keep inputs simple, for WTP we assume the order is Ba-Ba, Kephri, Zebak, Akkha
	-- it doesn't make a significant difference if the order is shuffled, as the bosses
	-- generally have similar total point contributions
	local AVERAGE_WTP_LEVEL = {[0] = 0, [1] = 0, [2] = 0, [3] = 0}
	if walk_the_path then
		AVERAGE_WTP_LEVEL = {[0] = 0, [1] = 4/6, [2] = 7/6, [3] = 13/6}
	end
	local points_table = {
		{name = "Akkha", hp = scale_hp(480, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[3]), mult = 1.0, approx = walk_the_path},
		{name = "Akkha's Shadow", hp = scale_hp(70, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[3]), mult = 1.0, count = 4}, -- path levels affect hp
		{name = "Baboons", hp = scale_hp(300, raid_level, team_size, 0), mult = 1.2, approx = true},
		{name = "Ba-Ba", hp = scale_hp(380, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[0]), mult = 2.0, approx = walk_the_path},
		{name = "Zebak", hp = scale_hp(580, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[2]), mult = 1.5, approx = walk_the_path},
		{name = "Scarab Swarms", hp = 200, mult = 1.0, approx = true},
		{name = "Scarabs", hp = 200, mult = 0.5, approx = true},
		{name = "Kephri's Shield", hp = scale_hp(375, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[1]), mult = 1.0, approx = true}, -- assuming the shield regenerates a total of 150% across two regen cycles - probably less in good groups
		{name = "Kephri", hp = scale_hp(130, raid_level, team_size, path_invocation + AVERAGE_WTP_LEVEL[1]), mult = 1.0, approx = walk_the_path},
		{name = "P1 Obelisk", hp = scale_hp(260, raid_level, team_size, 0), mult = 1.5},
		{name = "P2 Warden", hp = scale_hp(140, raid_level, team_size, 0), mult = 2.0, count = 2},
		{name = "P3 Warden", hp = scale_hp(880, raid_level, team_size, 0), mult = 2.5},
		{name = "Misc. damage", hp = 80 * team_size, mult = 1.0, approx = true},
		{name = "Het - seal mining", hp = 130 + math.floor(95.81 * (team_size - 1)), mult = 2.5}, -- not sure why this is the scaling, but it matches at 1, 2, 3, 4, 7
		{name = "Scabaras - puzzles", points = 300 * team_size, approx = true}, -- assuming 3 puzzles per person
		{name = "Apmeken - traps", points = 450 * team_size, approx = true}, -- assuming 6 fix-trap cycles, 75 points each
		{name = "Crondis - palm watering", points = 400 * team_size}, -- 2 points per health, 200 health per person
		{name = "MVP", points = 2700 * team_size}, -- 9 rooms, 300 per person
	}
	local total_points = 0
	for _, v in ipairs(points_table) do
		if v.points then
			total_points = total_points + v.points
		else
			total_points = total_points + math.floor(v.hp * v.mult * (v.count or 1))
		end
	end
	return {total = total_points, points_table = points_table}	
end

function p.get_rewards(points, raid_level, team_size, leagues_harder_mode)
	local adjusted_raid_level = raid_level
	if raid_level > 400 then
		adjusted_raid_level = 400 + math.floor((raid_level - 400) / 3)
	end

	local purple_denominator = 100 * (10500 - 20 * adjusted_raid_level)
	local pet_denominator
	
	if leagues_harder_mode then -- leagues none sense
		purple_denominator = math.max(purple_denominator, points) -- no cap fr fr
		pet_denominator = 100 * (350000 - 700 * math.min(adjusted_raid_level, 466)) -- we dont know if this is correct
	else
		purple_denominator = math.max(purple_denominator, 150000) -- caps at 1500
		pet_denominator = 100 * (350000 - 700 * adjusted_raid_level)
	end
	
	local elite_clue_denominator = 200000
	
	local scale = 1
	local description = ""
	if raid_level < 150 then
		scale = 0.75
		description = "decreased by '''25%'''"
	elseif raid_level >= 300 then
		local percent = math.floor((raid_level - 300) / 5) + 15
		scale = 1 + percent / 100
		description = string.format("increased by '''%d%%'''", percent)
	end
	
	if description ~= "" then
		description = string.format("With a raid level of '''%d''', normal loot is %s from the baseline.", raid_level, description)
	end
	
	description = "Normal loot always gives 3 rolls, and each of the 27 items is equally likely. " .. description
	
	normal_loot = {}
	
	for _, v in ipairs(NORMAL_LOOT) do
		local quantity = math.floor(points / team_size / v.divisor)
		quantity = math.floor(quantity * scale)
		quantity = math.max(quantity, 1)
		local price = 0
		if v.item == "Cache of runes" then
			price = 1700 / 3 * (gePrice("Blood rune") + gePrice("Soul rune") + gePrice("Death rune"))
		elseif v.item == "Coins" then
			price = 1
		else
			price = gePrice(v.item)
		end
		table.insert(normal_loot, {item = v.item, quantity = quantity, price = price})	
	end
	
	local base_purple_rate = math.min(points / purple_denominator, 0.55)
	
	local unique_loot = {}
	local true_purple_rate = 0.0
	
	for _, v in ipairs(PURPLES) do
		local true_rate = base_purple_rate * v.weight / 24
		if raid_level < v.level then
			true_rate = true_rate / 50
		end
		true_purple_rate = true_purple_rate + true_rate
		table.insert(unique_loot, {item = v.item, chance = true_rate / team_size, price = gePrice(v.item)})
	end
	table.insert(unique_loot, {item = "Tumeken's guardian", chance = points / pet_denominator / team_size, price = nil})
	table.insert(unique_loot, {item = "Clue scroll (elite)", chance = points / elite_clue_denominator / team_size, price = nil})
	
	return {
		purple = true_purple_rate,
		pet = points / pet_denominator,
		unique_loot = unique_loot,
		normal_loot = normal_loot,
		normal_loot_description = description
	}
end

function p.calculator(raid_level, team_size, path_invocation, walk_the_path, leagues_harder_mode) 
	if raid_level > 600 and not leagues_harder_mode then
		return "What? How are you doing a level " .. tostring(raid_level) .." raid?\n\nHow is that possible? Is this bug abuse?"
	end
	local points_breakdown = p.estimate_points(raid_level, team_size, path_invocation, walk_the_path)
	local rewards_breakdown = p.get_rewards(points_breakdown.total, raid_level, team_size, leagues_harder_mode)
	preamble = string.format(
[[With no deaths, this raid will give approximately '''%s''' contribution points%s. With a raid level of '''%s''', this equates to a '''%.4f%%''' chance of receiving a purple drop%s, and a '''%.4f%%''' chance of receiving the pet%s.

This estimate is not exact, and may not match your true point total for various reasons, including:
* Deaths (the penalty for deaths cannot feasibly be estimated because it depends on how far you've gotten in the raid)
* Walk the Path levelling order (this assumes the order is Ba-Ba, Kephri, Zebak, Akkha – different orders may result in very marginally different average points)
* Higher or lower healing from Kephri's shields than estimated (this assumes that the shields mostly regenerate both times, but in good teams they may not, resulting in faster kills but ~2%% fewer total points)
* Additional P2 Warden cycles (this assumes "2-down", but "3-down" results in about 3%% more total points)
* Challenge room trap failures
* Precision loss due to integer truncation in RuneScript (estimated 0.5-1%% point loss)
%s
If you are doing this type of raid repeatedly, try comparing the estimated normal loot to the amount you actually tend to receive. For example, if you are consistently getting 10%% fewer coins than predicted here, your purple chance and pet chance will also be 10%% lower than predicted.]],
		lang:formatNum(points_breakdown.total),
		(team_size > 1) and " for the entire team" or "",
		raid_level,
		 100 * rewards_breakdown.purple,
		(team_size > 1) and " across the entire team" or "",
		100 * rewards_breakdown.pet,
		(team_size > 1) and " across the entire team" or "",
		(team_size > 1) and  "\nFor computing average loot in the sections, group raids assume that all contribution is split evenly among the team, and chances are shown '''per person'''.\n" or ""
	)
	
	local reward_table = mw.html.create('table'):addClass('wikitable')
	local reward_table_data = {}
	local total_value = 0
	table.insert(reward_table_data, {{tag='th', text="Item", attr={colspan=2}}, {tag = 'th', text = "Chance"}, {tag = 'th', text = "Value"}})
	for _, v in ipairs(rewards_breakdown.unique_loot) do
		total_value = total_value + v.chance * (v.price or 0)
		table.insert(reward_table_data, {
			{text = string.format("[[File:%s.png|center|link=%s]]", v.item, v.item)},
			{text = string.format("[[%s]]", v.item)},
			{text = string.format("%.4f%%", 100 * v.chance)},
			{text = v.price and coins(v.price) or "N/A", class=v.price and "" or "table-na", css={["text-align"] = "center"}}
		})	
	end
	table.insert(reward_table_data, {{attr={colspan=3}, text="Expected value", tag='th'}, {text=coins(math.floor(total_value)), tag='th'}})
	
	tables._table(reward_table, reward_table_data)
	
	local normal_loot_table = mw.html.create('table'):addClass('wikitable')
	local normal_loot_table_data = {}
	total_value = 0
	table.insert(normal_loot_table_data, {{tag='th', text="Item", attr={colspan=2}}, {tag = 'th', text = "Quantity"}, {tag = 'th', text = "Value"}})
	
	for _, v in ipairs(rewards_breakdown.normal_loot) do
		total_value = total_value + v.price * v.quantity * 3/27
		table.insert(normal_loot_table_data, {
			{text = string.format("[[File:%s.png|center|link=%s]]", v.item, v.item)},
			{text = string.format("[[%s]]", v.item)},
			{text = lang:formatNum(v.quantity)},
			{text = coins(math.floor(v.quantity * v.price))}	
		})
	end
	table.insert(normal_loot_table_data, {{attr={colspan=3}, text="Expected value (3 rolls)", tag='th'}, {text=coins(math.floor(total_value)), tag='th'}})
	tables._table(normal_loot_table, normal_loot_table_data)
	
	local points_table = mw.html.create('table'):addClass('wikitable')
	local points_table_data = {}
	table.insert(points_table_data, {{tag='th', text="Type"}, {tag = 'th', text = "Est. damage"}, {tag = 'th', text = "Multiplier"}, {tag = 'th', text = "Est. points"}})
	
	for _, v in ipairs(points_breakdown.points_table) do
		local name = v.name
		if v.count then
			name = string.format("%s (%sx)", name, v.count)
		end

		local row = {{text = name}}
		if v.hp then
			local damage = lang:formatNum(v.hp)
			if v.approx then
				damage = string.format("~%s", damage)	
			end
			table.insert(row, {text = damage})
			table.insert(row, {text = v.mult})
		else
			table.insert(row, {text = "N/A", attr={colspan=2}, class="table-na", css={["text-align"] = "center"}})
		end
		local points = 0
		if v.points then
			points = v.points
		else
			points = math.floor(v.hp * v.mult * (v.count or 1))
		end
		table.insert(row, {text = lang:formatNum(points)})
		table.insert(points_table_data, row)
	end
	local label = "Est. total points"
	if team_size > 1 then
		label = "Est. total points (entire team)"
	end
	table.insert(points_table_data, {{attr={colspan=3}, text=label, tag='th'}, {text=lang:formatNum(points_breakdown.total), tag='th'}})
	tables._table(points_table, points_table_data)	
	return preamble 
		.. "\n==Average rewards per person==\n" .. tostring(reward_table) 
		.. "\n===Normal loot===\n" .. rewards_breakdown.normal_loot_description .. "\n" .. tostring(normal_loot_table)
		.. "\n==Point estimation==\n" .. tostring(points_table)
end
return p