Module:FocusRates/test

local p = {} local cargo = mw.ext.cargo local Util = require 'Module:Util' local List = require 'Module:ListUtil' local Hash = require 'Module:HashUtil' local Datetime = require 'Module:DatetimeUtil' local escq = require 'Module:EscQ'.main1 local lang = mw.getContentLanguage

local COLORS = {'Red', 'Blue', 'Green', 'Colorless'} local MAX_RARITY = 5

local _focusRates = function (startDate, rateTable, heroTable, isNew, stones) --Meaning of isNew variable: True if the focus is a focus that starts after 2019-04-10 and is either a New Heroes or Special Heroes summoning focus (It is not a Special Heroes Revival etc) --Stones: Number of colored orbs that show up in one summoning session. Assumed to be 5 by default

-- nonFocusHeroes contains every Hero that can be summoned for this event but isn't a focus unit. nonFocusHeroes[i] = {page=,Rarity=,Color= } local nonfocusHeroes = cargo.query('SummoningAvailability,Units,WeaponTypes', 'Units._pageName=page,Rarity,Color', {		join = 'SummoningAvailability._pageName=Units._pageName,Units.WeaponType=WeaponTypes.WikiName',		where = ("IFNULL(Properties__full,'') NOT LIKE '%%enemy%%' AND ('%s' BETWEEN StartTime AND EndTime) %s"):format( Datetime.to_cargo(Datetime.from_cargo(startDate) + 7 * 60 * 60), isNew and 'AND NewHeroes' or ''),		groupBy = 'Units.WikiName,Rarity',		limit = 5000,	}) for _, v in ipairs(nonfocusHeroes) do		v.Rarity = tonumber(v.Rarity) end local focusHeroes = cargo.query('Units,WeaponTypes', 'Units._pageName=page,Color', {		join = 'Units.WeaponType=WeaponTypes.WikiName',		where = ("IFNULL(Properties__full,'') NOT LIKE '%%enemy%%' AND Units._pageName IN ('%s')"):format( table.concat(List.map(heroTable, escq), "','")),		groupBy = 'Units.WikiName',		orderBy = 'IFNULL(IntID,2147483647)', --TODO: Preserve the order of Heroes as they are input (heroTable) instead of sorting by IntID		limit = 5000,	})

local nonfocusHeroesByColor = List.group_by(nonfocusHeroes, function (v) return v.Color end) local focusHeroesByColor = List.group_by(focusHeroes, function (v) return v.Color end) for _, col in ipairs(COLORS) do		nonfocusHeroesByColor[col] = nonfocusHeroesByColor[col] or {} focusHeroesByColor[col] = focusHeroesByColor[col] or {} end

-- heroCounts[rarity]["nonfocus" / "focus"][color] local heroCounts = List.generate(MAX_RARITY, function (r)		local nonfocus = Hash.map(nonfocusHeroesByColor, function (vs) return List.count_if(vs, function (v) return v.Rarity == r end) end)		local focus = Hash.map(focusHeroesByColor, function (vs) return rateTable[r].focus > 0 and #vs or 0 end)		nonfocus.Total = List.sum(Hash.values(nonfocus))		focus.Total = List.sum(Hash.values(focus))		return {			nonfocus = nonfocus,			focus = focus,		}	end)

-- Chance for a random orb to be a certain color. local unitIs = Hash.generate(COLORS, function (color)		return List.sum(List.zip(heroCounts, rateTable, function (counts, rate)			return (counts.focus[color] == 0 and 0 or rate.focus * counts.focus[color] / counts.focus.Total) +				(counts.nonfocus[color] == 0 and 0 or rate.nonfocus * counts.nonfocus[color] / counts.nonfocus.Total)		end))	end) -- Chance for each color to pity break (summon a 5 star). Contains one entry for each color. Entries are unordered. local colorPityBreakRates = {} -- colorPityBreakRates[i] = {COLORS index, breakRate} for k, col in ipairs(COLORS) do		local breakRate = 0 -- Aka Rate for any 5star | Orb color --[==[ breakRate = ( P(5 star focus | orb is col color) * [number of focus units that are col color] ) + ( P(5 star nonfocus | orb is col color) * [number of 5 star nonfocus units that are col color] ) ]==]		for focusType, rate in pairs(rateTable[5]) do			if heroCounts[5][focusType].Total > 0 then breakRate = breakRate + (heroCounts[5][focusType][col] * rate / heroCounts[5][focusType].Total / unitIs[col]) end end colorPityBreakRates[#colorPityBreakRates+1] = {k, breakRate} end

local simulationWikiText = "" --Simulation that aims to find the number of orbs required to summon a specific 5 star focus unit. Assumes the summoner plays with these behaviors:	*Plays optimally. They always enter the summoning session with enough orbs to summon all orbs if needed.	*Only cares about the specific unit they are trying to summon.	*If no orbs of the target color (color that the target Hero is) shows up, select the from the available colors the one that has the smallest chance to pity break if (not (rateTable[4].focus > 0)) and rateTable[5].focus > 0 and rateTable[3].nonfocus > 0 and rateTable[4].nonfocus > 0 and MAX_RARITY == 5 then -- Only run the simulation for 3/4/5/5F and 3/4/5F since it is known exactly how pity affects rates for only these, while increases/decreases for 4&5 star focuses or other are unknown. https://www.reddit.com/r/FireEmblemHeroes/comments/7gham0/proof_that_the_1950_pity_rate_post_is_fake_call/ local RATE_CHANGE = 0.005 -- It has always been 0.5% so far. In the future, this variable may have to accept an input if Intelligent Systems changes the pity rate rate local SUMMONS_FOR_AUTO_5_STAR = 120 -- number of summons needed to trigger the automatic 100% 5★ heroes local orbsSpent = {} -- orbsSpent[color_index] is the total amount of orbs spent across all simulations for that color. -- Divide by NUMBER_OF_SIMULATIONS to get the average (mean). local leastLikelyToPityBreakColor = {} -- Color indexes ordered from least likely to break pity rate (least likely to summon a 5 star) to most. local orbCost = {5, 4, 4, 4, 3} for k,_ in ipairs(COLORS) do			orbsSpent[k] = 0 end

table.sort(colorPityBreakRates, function(v1,v2) return v1[2] < v2[2] end) for i=1,#colorPityBreakRates do			leastLikelyToPityBreakColor[i] = colorPityBreakRates[i][1] end local boundsForColorInRarity = {} -- Precalculated bounds table for rng, must be iterated in order of COLORS table to work. for rarity=1,MAX_RARITY do			boundsForColorInRarity[rarity] = {} for focusType,focusTypeTable in pairs(heroCounts[rarity]) do				boundsForColorInRarity[rarity][focusType] = {} local bound = 0 for k,col in ipairs(COLORS) do					bound = bound + focusTypeTable[col] boundsForColorInRarity[rarity][focusType][k] = bound end -- No need to store the total, the highest bound will always equal the total end end local highestColorIndexBound = #COLORS local boundsForRarity = {} -- A precalculated lookup table for rarity. Final form: boundsForRarity[numberofsummons] = {3 bound, 4 bound, 5 bound, 5f bound}. boundsForRarity[0] = { rateTable[3].nonfocus, rateTable[3].nonfocus + rateTable[4].nonfocus, rateTable[3].nonfocus + rateTable[4].nonfocus + rateTable[5].nonfocus, 1 -- Just set the last bound to 1 manually to avoid floating point lua problems (https://stackoverflow.com/q/6366954). 5 Focus now has about 1 * 10^-16 more to its rate than it is supposed to		} do local r3and4 = rateTable[3].nonfocus + rateTable[4].nonfocus local r5and5f = rateTable[5].focus + rateTable[5].focus local delta_rate_3 = RATE_CHANGE * rateTable[3].nonfocus/r3and4 local delta_rate_4 = RATE_CHANGE * rateTable[4].nonfocus/r3and4 local delta_rate_5 = RATE_CHANGE * rateTable[5].nonfocus/r5and5f --local delta_rate_5f = RATE_CHANGE * rateTable[5].focus/r5and5f for n=1,math.floor(SUMMONS_FOR_AUTO_5_STAR/5)+1 do				local rate_3 = rateTable[3].nonfocus - (n * delta_rate_3) local rate_4 = rateTable[4].nonfocus - (n * delta_rate_4) local rate_5 = rateTable[5].nonfocus + (n * delta_rate_5) --local rate_5f = rateTable[5].focus + (n * delta_rate_5f) boundsForRarity[n*5] = { rate_3, rate_3 + rate_4, rate_3 + rate_4 + rate_5, --rate_3 + rate_4 + rate_5 + rate_5f 1 -- Set last bound to 1 manually to avoid floating point problems }			end for numberofsummons=1,SUMMONS_FOR_AUTO_5_STAR-1 do				boundsForRarity[numberofsummons] = boundsForRarity[numberofsummons - (numberofsummons % 5)] -- x - (x % 5) gets the previous multiple of 5 end boundsForRarity[SUMMONS_FOR_AUTO_5_STAR] = {0, 0, 1, nil} for i=SUMMONS_FOR_AUTO_5_STAR+1,SUMMONS_FOR_AUTO_5_STAR+4 do				boundsForRarity[i] = boundsForRarity[SUMMONS_FOR_AUTO_5_STAR] end end --[=[		By the way, the only source of information for the 120+ summon auto 5★ mechanic is from the information on summoning page in-game, since nobody has achieved 120+ summons without a 5★ yet. This source is unclear, stating that the chance to summon a 5 star Hero will be set to 100%, but doesn't mention if that is the focus rate specifically, the non focus rate, or the 100% is split between them in some way. English text is as follows: The increase is applied to both 5★ Heroes and 5★ Focus Heroes and added to the previous rate. It is split up to apply 0.25% to the rates for 5★ Heroes and 5★ Focus Heroes. If you summon 120 times in a row without summoning a 5★ Hero, then the summoning rate for 5★ Heroes will be raised up to 100% for the next summoning session. When that happens, summoning with any of the give summoning stones will result in a 5★ Hero.

The Japanese text, transcribed below, is also unclear.

※★5と★5ピックアップの上昇量は、元の提供割合に応じて きまります. ★5と★5ピックアップが同率の場合は均等に 0.25%ずつ上昇します. 120回以上連続で★5英雄が召喚されない場合は、次回の召 喚時に★5英雄の提供割合が特別に100%になります. この時は、5つの召喚石全てから★5英雄が召喚されます. ]=]		math.randomseed(Datetime.from_cargo(startDate)+heroCounts[5].nonfocus.Total) local numberOfFocusColors = 0 for _,col in ipairs(COLORS) do			if heroCounts[5]["focus"][col] > 0 then numberOfFocusColors = numberOfFocusColors + 1 end end local NUMBER_OF_SIMULATIONS = math.ceil(60000 / numberOfFocusColors) -- Optimization tips https://www.lua.org/gems/sample.pdf, https://springrts.com/wiki/Lua_Performance

-- TODO: implement median maybe? https://stackoverflow.com/q/638030

--Begin the simulation -- local x = os.clock for target_color_index=1,#COLORS do -- What is the color of the focus Hero you are sniping for? This is aimed at focus colors rather than specific Heroes since there is no difference in rates between summoning (for example) an Amelia: Rose of the War and an Olwen: Righteous Knight (both green) if they are both focus units. This way, less simulations need to be run. local target_color = COLORS[target_color_index] if heroCounts[5]["focus"][target_color] > 0 then local focus5ColorBound = boundsForColorInRarity[5].focus[target_color_index] --Accessing local variables is faster local mathrandom = math.random -- Also I ran a test with 2 million results, math.random appears to suitably generate uniform random results local boundsForRarity = boundsForRarity local boundsForColorInRarity = boundsForColorInRarity local orbCost = orbCost local orbsSpent = orbsSpent

for _=1,NUMBER_OF_SIMULATIONS do					local totalOrbsSpent = 0 local summonsWithout5Star = 0 local targetUnitsAcquired = 0 -- Instead of stopping immediately after summoning 1 target unit, the remaining orbs of target_color are summoned from in the session, then the simulation ends (for that summoner). The orbs spent for that summoner are calculated as orbs spent / units acquired. This is to account for the situations where two+ target units are summoned in the same session and for the tiny portion of the pity rate used for the extra target unit(s). This is more realistic behavior for those trying to +10 a unit and playing optimally. Overall though, this behavior should have basically no effect beyond very slightly lowering the orbs spent. You can observe its effect by setting the focus rate to 100%, and simulating a focus with 1 unit, where the orbs used will be 4 (with this behavior) compared to 5 (without this behavior). repeat -- Each loop, one summoning session is gone through. local target_color_heroes = {nil, nil, nil, nil, nil} -- Holds the heroes in the session that are summoned that match the current target color being sniped. target_color_heroes = {hero1data, hero2data,...} herodata: rarity local consolation_color_heroes = {nil, nil, nil, nil} -- Holds the Heroes that do NOT match the target color being sniped. Only holds one for each color, because in the event that no target color orbs show up, only one orb would be summoned from. consolation_color_heroes[color_index] = rarity --By the way, putting {nil, nil, nil, nil} does have an effect despite the performance guide using a non-nil boolean value to fill the array for _=1,stones do							local heroRarityToSummon local heroFocusTypeToSummon local heroColorIndex --Select the rarity to summon from local rn = mathrandom local bounds = boundsForRarity[summonsWithout5Star] if rn < bounds[1] then heroRarityToSummon = 3 heroFocusTypeToSummon = "nonfocus" elseif rn < bounds[2] then heroRarityToSummon = 4 heroFocusTypeToSummon = "nonfocus" elseif rn < bounds[3] then heroRarityToSummon = 5 heroFocusTypeToSummon = "nonfocus" elseif rn < bounds[4] then heroRarityToSummon = 5 heroFocusTypeToSummon = "focus" else -- This should never happen error("Something went wrong with the simulation.") end -- Finished selecting rarity -- Select color (select which Hero to summon in the actual game, but only their color information is important here) local poolBounds = boundsForColorInRarity[heroRarityToSummon][heroFocusTypeToSummon] rn = mathrandom(poolBounds[highestColorIndexBound]) -- Comparison will be <= instead of <, since the range of numbers is [1,n] instead of [0,n-1]/[0,n)							for i=1,#COLORS do								if rn <= poolBounds[i] then									heroColorIndex = i									break								end							end							-- Finished selecting the hero							if heroColorIndex == target_color_index then								if (rn == focus5ColorBound) and (heroFocusTypeToSummon == "focus") and (heroRarityToSummon == 5) then -- checks to see whether the unit is the specific target unit									targetUnitsAcquired = targetUnitsAcquired + 1 -- we can increment this preemptively because all target_color orbs will be summoned from anyway. This allows some space to be saved								end								target_color_heroes[#target_color_heroes+1] = heroRarityToSummon							else								consolation_color_heroes[heroColorIndex] = heroRarityToSummon							end						end if #target_color_heroes > 0 then for i=1,#target_color_heroes do								totalOrbsSpent = totalOrbsSpent + orbCost[i] if target_color_heroes[i] == 5 then --If they pity break summonsWithout5Star = 0 else summonsWithout5Star = summonsWithout5Star + 1 end -- If then else is faster than and or ternary operator end else for i=1,#leastLikelyToPityBreakColor do -- Go through the list of colors from least to most likely to break pity rate local color_index = leastLikelyToPityBreakColor[i] if consolation_color_heroes[color_index] then -- If a consolation hero exists for that color totalOrbsSpent = totalOrbsSpent + 5 if consolation_color_heroes[color_index] == 5 then --If they pity break summonsWithout5Star = 0 else summonsWithout5Star = summonsWithout5Star + 1 end break end end end until targetUnitsAcquired > 0 orbsSpent[target_color_index] = orbsSpent[target_color_index] + (totalOrbsSpent / targetUnitsAcquired) end end end -- mw.log(string.format("Simulation execution time: %.2f", os.clock - x)) for i=1,#COLORS do -- Reorganize orbsSpent with the color strings being the indexes instead of the color index number, so they can be accessed with hero.Color later orbsSpent[COLORS[i]] = orbsSpent[i] orbsSpent[i] = nil end local simulation_table = mw.html.create("table") :addClass("wikitable") :css("text-align","center") local tr = simulation_table:tag("tr") tr:tag("th"):wikitext("Hero") tr:tag("th"):wikitext("Average orbs used")

for _, hero in ipairs(focusHeroes) do local tr = simulation_table:tag("tr") tr:tag("td"):wikitext(mw.ustring.format("%s",hero.page)) tr:tag("td"):wikitext(("%.1f"):format(orbsSpent[hero.Color] / NUMBER_OF_SIMULATIONS)) end

simulationWikiText = tostring(mw.html.create("h3"):wikitext("Simulation")) .. "The following table documents the results of a simulation where "..lang:formatNum( NUMBER_OF_SIMULATIONS )..[=[ simulations are run per focus unit. In each simulation, an AI plays optimally and tries to summon a specific focus unit using the least amount of orbs possible. The method of play is described below: ]=] .. tostring(simulation_table) .. "\n" end
 * Summon from only from the colored orbs that are the same color as the targeted unit.
 * If no such orb exists in a session, summon from the color that has the smallest chance to summon a 5★ Hero, then exit that session.

-- Initialize the table local tbl = mw.html.create('table') :addClass('wikitable'):addClass('unsortable') :css('text-align', 'center') :css('width', '400px') tbl:tag("caption"):wikitext("Orb color distribution")

-- Table headers local tr = tbl:tag('tr') tr:tag("th") for _, color in ipairs(COLORS) do		tr:tag('th'):css('width', '25%'):wikitext(color) end

-- build cell contents for each color tr = tbl:tag('tr') local tr2 = tbl:tag("tr") tr:tag("th"):wikitext("Individual") tr2:tag("th"):wikitext("All") for _, color in ipairs(COLORS) do		local indv = unitIs[color] tr:tag('td'):wikitext(("%.2f%%"):format(indv * 100)) tr2:tag('td'):wikitext(("%.2f%%"):format((indv ^ stones)*100)) end

tr = tbl:tag("tr") tr2 = tbl:tag("tr") tr:tag("th"):wikitext("At least one") tr2:tag("th"):wikitext("None") for _, color in ipairs(COLORS) do		local noneRate = (1 - unitIs[color]) ^ stones tr:tag('td'):wikitext(("%.2f%%"):format((1 - noneRate) * 100)) tr2:tag('td'):wikitext(("%.2f%%"):format(noneRate * 100)) end

local nonfocusRarities = List.select(List.range(MAX_RARITY, 1), function (r) return rateTable[r].nonfocus > 0 end) local focusRarities = List.select(List.range(MAX_RARITY, 1), function (r) return rateTable[r].focus > 0 end)

local counttbl = mw.html.create('table') :addClass('wikitable'):addClass('sortable') :css('text-align', 'center') :css('width', '400px') counttbl:tag("caption"):wikitext("Number of units in a pool") tr = counttbl:tag("tr") tr:tag("th") for _, rarity in ipairs(focusRarities) do tr:tag("th"):wikitext(rarity .. "★ Focus") end for _, rarity in ipairs(nonfocusRarities) do tr:tag("th"):wikitext(rarity .. "★") end for _,color in ipairs(COLORS) do		tr = counttbl:tag("tr") tr:tag("th"):wikitext(color) for _,rarity in ipairs(focusRarities) do			tr:tag("td"):wikitext(heroCounts[rarity].focus[color]) end for _,rarity in ipairs(nonfocusRarities) do			tr:tag("td"):wikitext(heroCounts[rarity].nonfocus[color]) end end

-- Initialize focus specific table local ftbl = mw.html.create('table') :addClass('wikitable'):addClass('unsortable') :css('text-align', 'center') :css('width', '400px') ftbl:tag("caption"):wikitext("Specific Hero rates (any rarity)") tr = ftbl:tag("tr") tr:tag("th"):wikitext("Hero") tr:tag("th"):wikitext("Appearance rate") tr:tag("th"):wikitext("Hero Rate | Orb color")

for _, hero in ipairs(focusHeroes) do		local apRate = 0 -- Total appearance rate local orbRate = 0 -- Rate | Orb color of getting the unit

for rarity, rate in ipairs(rateTable) do			if heroCounts[rarity].focus.Total > 0 then apRate = apRate  + rate.focus / heroCounts[rarity].focus.Total orbRate = orbRate + rate.focus / heroCounts[rarity].focus.Total / unitIs[hero.Color] end if List.find_if(nonfocusHeroes, function (v) return v.page == hero.page and v.Rarity == rarity end) then if heroCounts[rarity].nonfocus.Total > 0 then apRate = apRate  + rate.nonfocus / heroCounts[rarity].nonfocus.Total orbRate = orbRate + rate.nonfocus / heroCounts[rarity].nonfocus.Total / unitIs[hero.Color] end end end

tr = ftbl:tag("tr") tr:tag("td"):wikitext(mw.ustring.format("%s",hero.page)) tr:tag("td"):wikitext(("%.2f%%"):format(apRate * 100)) tr:tag("td"):wikitext((" %.2f%%"):format(hero.Color, orbRate * 100)) end local tbl_5 = mw.html.create("table") :addClass("wikitable") :css("text-align","center") tbl_5:tag("caption"):wikitext("5★ Hero rates") tr = tbl_5:tag("tr") tr:tag("th") tr:tag("th"):wikitext("Appearance rate") tr:tag("th"):wikitext("5★ rate | Orb color") local trs = {} for i=1,#COLORS do		local color=COLORS[i] local tr = tbl_5:tag("tr") tr:tag("th"):wikitext(COLORS[i]) local apRate = 0 if heroCounts[5].focus.Total > 0 then apRate = apRate  + rateTable[5].focus * heroCounts[5].focus[color]  / heroCounts[5].focus.Total end if heroCounts[5].nonfocus.Total > 0 then apRate = apRate  + rateTable[5].nonfocus * heroCounts[5].nonfocus[color]  / heroCounts[5].nonfocus.Total end tr:tag("td"):wikitext(("%.2f%%"):format(apRate * 100)) trs[i] = tr	end for _,v in ipairs(colorPityBreakRates) do		local tr = trs[v[1]] tr:tag("td"):wikitext((" %.2f%%"):format(COLORS[v[1]],v[2] * 100)) end

return tostring(mw.html.create("h3"):wikitext("Statistics")) .. tostring(tbl) .. tostring(counttbl) .. tostring(ftbl) .. tostring(tbl_5) .. [[

]] .. simulationWikiText end
 * The orb color distribution table displays the chances for a certain orb color to appear.
 * Individual: The chance for a randomly selected orb to be a certain color.
 * All: The chance for all orbs to be a certain color.
 * At least one: Chance for at least one of this orb color to show up in a summoning session.
 * None: Chance for no orbs of this color to show up in a summoning session.
 * The specific Hero rates table shows the chances for specific Heroes.
 * Appearance rate: Individual Hero appearance rate. This is the chance for a randomly selected orb to contain the Hero. This includes all non-focus rarities of the Hero as well in addition to focus rarities. This rate × ]]..stones.. is the chance for the Hero to show up at all in one of the ..stones..[[ orbs in a summoning session.
 * Rate | Orb color: The chance to summon a specific Hero given that their orb color has appeared and is being summoned from. This is dependent on color distribution of units in all the pools. This includes all non-focus rarities of the Hero as well in addition to focus rarities.

function p.focusRates(frame, isNew) local args = frame.args if Util.isNilOrEmpty(args.start) then return "" end local startDate = lang:formatDate( "Y-m-d", args.start ) local rateTable = List.generate(MAX_RARITY, function (r)		local rate = {nonfocus = 0, focus = 0}		-- Strip out to % symbol from arguments and converts to number		local rateStr = args["rarity" .. r .. "Percent"]		if not Util.isNilOrEmpty(rateStr) then			local percent = tonumber((mw.ustring.gsub(rateStr, '%%$', )))			if percent then				rate.nonfocus = percent / 100 -- divide by 100 to convert to non percentage format			end		end		local focusRateStr = args["rarity" .. r .. "FocusPercent"]		if not Util.isNilOrEmpty(focusRateStr) then			local percent = tonumber((mw.ustring.gsub(focusRateStr, '%%$', )))			if percent then				rate.focus = percent / 100 -- divide by 100 to convert to non percentage format			end		end		return rate	end)

local sum = List.sum(List.map(rateTable, function (v) return v.nonfocus + v.focus end)) if math.abs(sum - 1) > 1e-7 then return (require "Module:Error").error("Error: Percentages do not add up to 100%.") end local heroTable = {} local i = 1 while not Util.isNilOrEmpty(args["hero" .. i]) do heroTable[#heroTable + 1] = args["hero" .. i]		i = i + 1 end

return _focusRates(startDate, rateTable, heroTable, isNew, tonumber(args.stones) or 5) end

return p