Module:MixupTest: Difference between revisions

From Wavu Wiki, the 🌊 wavy Tekken wiki
mNo edit summary
mNo edit summary
Line 316: Line 316:
for i,strat in ipairs(game.strats[1]) do
for i,strat in ipairs(game.strats[1]) do
for j,_ in ipairs(game.strats[2]) do
for j,_ in ipairs(game.strats[2]) do
local outcome = game.outcomes[ game.map[j][i] ][1]
local rat = game.outcomes[ game.map[j][i] ][1]
local val = rat.num / rat.den
local val = rat.num / rat.den
minVal = math.min(minVal, val)
minVal = math.min(minVal, val)

Revision as of 21:43, 17 May 2024

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

local rational = require( "Module:Rational" )
local yesno = require( "Module:Yesno" )
local G = require( "Module:GTest" )

local T_NUMBER = 0
local T_STRING = 1
local T_SYMBOL = 2
local T_LBRACE = 3
local T_RBRACE = 4
local T_COMMA = 5
local T_EOF = 6

local p = {}

-- NFG parsing based on https://github.com/gambitproject/gambit/blob/master/library/src/file.cc

p._nfgLexer = function(nfg)
	local i = 1
	local line = 1
	local len = string.len( nfg )
	local fail = function (reason)
		if reason == nil then reason = "Invalid NFG syntax" end
		error(reason .. " at line " .. line
			.. "\nc=" .. string.sub( nfg, i, i )
			.. " around=" .. string.sub(nfg, i-5, i+5))
	end
	local currToken = nil
	local nextToken = nil
	local prevToken = nil
	local step = function ()
		-- Consume any space chars
		local _, r, spaces = string.find( nfg, "(%s+)", i )
		if r ~= nil then
			i = r+1
			for space in string.gmatch( spaces, "\n" ) do
				line = line+1
			end
		end
		
		-- End of string
		if i > len then return { T_EOF, "" } end
		
		local c = string.sub( nfg, i, i )
		if c == "{" then
			i = i+1
			return { T_LBRACE, c }
		elseif c == "}" then
			i = i+1
			return { T_RBRACE, c }
		elseif c == "," then
			i = i+1
			return { T_COMMA, c }
		elseif string.match(c, "%d") or c == "-" or c == "+" or c == "." then
			-- Number
			local _, r = string.find( nfg, "^[-+]?%d*", i )
			if r == nil then
				fail("Invalid number")
			end
		
			if string.sub( nfg, r+1, r+1 ) == "." then
				local p, q = string.find( nfg, "^%d+", r+2 )
				if p == nil then
					fail("Decimal with no digits")
				end
				r = q
			elseif ((c == "-" or c == "+") and i == r) then
				fail("Sign with no number")
			end
			
			if string.match( nfg, "^[eE]", r+1 ) then
				local p, q = string.find( nfg, "^%d+", r+2 )
				if p == nil then
					fail("Invalid exponent")
				end
				r = q
			end
			
			if string.sub( nfg, r+1, r+1 ) == "/" then
				local p, q = string.find( nfg, "^%d+", r+2 )
				if p == nil then
					fail("Invalid quotient")
				end
				r = q
			end
			
			local ret = string.sub( nfg, i, r )
			i = r+1
			return { T_NUMBER, ret }
		elseif c == '"' then
			-- String
			if string.sub( nfg, i+1, i+1 ) == '"' then
				i = i+2
				return { T_STRING, "" }
			else
				local l, r = string.find( nfg, '^".-[^\\]"', i)
				if l == nil then
					fail("Invalid string")
				end
				i = r+1
				return { T_STRING, string.sub(nfg, l+1, r-1):gsub('\\"', '"') }
			end
		else
			-- Symbol, consume all non-space chars
			local l, r = string.find( nfg, "^%S+", i )
			if r ~= nil then
				i = r+1
				return { T_SYMBOL, string.sub( nfg, l, r ) }
			else
				return { T_EOF, "" }
			end
		end
	end
	
	return {
		peek = function ()
			if nextToken == nil then
				nextToken = step()
			end
			return nextToken
		end,
		next = function ()
			if currToken ~= nil then
				prevToken = currToken
			end
			if nextToken ~= nil then
				currToken = nextToken
				nextToken = nil
			else
				currToken = step()
			end
			return currToken
		end,
		curr = function () return currToken end,
		prev = function () return prevToken end,
	}
end

p._ratParse = function (s)
	local r = mw.text.split(s, "/")
	n = r[1]+0
	d = (r[2] ~= nil and r[2] ~= 0) and (r[2]+0) or 1
	return rational(n,d)
end

p._nfgParse = function (nfg)
	local lexer = p._nfgLexer(nfg)
	local fail = function (reason)
		if reason == nil then reason = "Invalid NFG" end
		curr = lexer.curr()
		error(reason .. " on token type " .. curr[1] .. ": " .. curr[2])
	end
	local game = {
		players = {},
		outcomes = {},
		map = {},
		strats = {},
		comment = "",
	}
	local token
	
	-- Heading
	token = lexer.next()
	if token[1] ~= T_SYMBOL or token[2] ~= "NFG" then
		fail("NFG missing from start of NFG")
	end
	token = lexer.next()
	if token[1] ~= T_NUMBER or token[2] ~= "1" then
		fail("NFG version 1 not specified")
	end
	token = lexer.next()
	if token[1] ~= T_SYMBOL or token[2] ~= "R" then
		fail("Number type R not specified")
	end
	token = lexer.next()
	if token[1] ~= T_STRING then
		fail("Title missing from header")
	end
	game.title = token[2]
	
	-- Players
	if lexer.next()[1] ~= T_LBRACE then
		fail("Expecting { before players")
	end
	while lexer.peek()[1] == T_STRING do
		table.insert( game.players, lexer.next()[2] )
	end
	if #game.players ~= 2 then
		fail("Game has " .. #game.players .. " players, only 2 are supported")
	end
	if lexer.next()[1] ~= T_RBRACE then
		fail("Expecting } after players")
	end
	
	-- Strats
	if lexer.next()[1] ~= T_LBRACE then
		fail("Expecting { before strategies")
	end
	for i, p in ipairs(game.players) do
		if lexer.next()[1] ~= T_LBRACE then
			fail("Expecting { for player " .. i .. " strategies")
		end
		game.strats[i] = {}
		while lexer.peek()[1] == T_STRING do
			table.insert(game.strats[i], lexer.next()[2])
		end
		if lexer.next()[1] ~= T_RBRACE then
			fail("Expecting } for player " .. i .. " strategies")
		end
	end
	if lexer.next()[1] ~= T_RBRACE then
		fail("Expecting } after strategies")
	end
	
	-- Comment
	if lexer.peek()[1] == T_STRING then
		game.comment = lexer.next()[2]
	end
	
	-- Outcomes
	if lexer.next()[1] ~= T_LBRACE then
		fail("Expecting { before outcomes")
	end
	while lexer.next()[1] == T_LBRACE do
		token = lexer.next()
		if token[1] ~= T_STRING then
			fail("Expecting string for outcome")
		end
		local outcome = {}
		outcome_label = token[2]
		
		for i = 1,2 do
			token = lexer.next()
			if token[1] == T_COMMA then token = lexer.next() end
			if token[1] ~= T_NUMBER then
				fail("Expecting number for outcome")
			end
			table.insert(outcome, p._ratParse(token[2]))
		end
		table.insert(outcome, outcome_label)
		if (outcome[1] + outcome[2]).num ~= 0 then
			local sum = outcome[1] + outcome[2]
			fail("Expecting only zero-sum outcomes, "
				.. outcome[1].num .. "/" .. outcome[1].den .. " - "
				.. outcome[2].num .. "/" .. outcome[2].den .. " = "
				.. sum.num .. "/" .. sum.den
			)
		end
		if lexer.next()[1] ~= T_RBRACE then
			fail("Expecting } after outcome")
		end
		table.insert(game.outcomes, outcome)
	end
	if lexer.prev()[1] ~= T_RBRACE then
		fail("Expecting } after outcomes")
	end
	
	-- If an outcome hasn't been filled in, it has index 0
	-- Hopefully making a 0-indexed array here doesn't break anything >,<
	game.outcomes[0] = { rational(0,1), rational(0,1), "" }
	
	-- Outcome mapping
	for j,p2 in ipairs(game.strats[2]) do
		local row = {}
		for i,p1 in ipairs(game.strats[1]) do
			if lexer.next()[1] ~= T_NUMBER then
				fail("Expecting number for outcome index")
			end
			local index = lexer.curr()[2] + 0
			if game.outcomes[index] == nil then
				fail("Index to undefined outcome")
			end
			table.insert(row, index)
		end
		table.insert(game.map, row)
	end
	
	return game
end

p.render = function(frame)
	local page = frame.args[1]
	local collapsed = yesno( frame.args["collapsed"] )
	return p._render(page, collapsed)
end

p._render = function(page, collapsed)
	-- All .gbt files belong in the Mixup: namespace
	local title = mw.title.makeTitle('Mixup', page)
	local gbt = title:getContent()
	if gbt == nil then
		error("[[Mixup:" .. page .. "]] doesn't exist", 0)
	end
	
	local nfg = string.match(gbt, "<nfgfile[^>]->(.-)</nfgfile[^>]->")
	local game = p._nfgParse(nfg)
	--mw.logObject(game)
	
	--
	-- Create HTML for payoffs table
	--
	local h_payoffs = mw.html.create('table')
	local h_payoffs_cols = mw.html.create('tr'):node(
		mw.html.create('th'):addClass('bg-white')
			:node(mw.html.create('div'):attr('class', 'border-hack bottom border-black'))
			:node(mw.html.create('div'):attr('class', 'border-hack right border-black'))
	)
	for i,strat in ipairs(game.strats[2]) do
		h_payoffs_cols:node(
			mw.html.create('th'):attr('class', 'fg-p2 bg-white'):wikitext(strat)
				:node(mw.html.create('div'):attr('class', 'border-hack bottom border-black'))
		)
	end
	h_payoffs:node(h_payoffs_cols)
	local minVal = math.huge
    local maxVal = -math.huge
	for i,strat in ipairs(game.strats[1]) do
		for j,_ in ipairs(game.strats[2]) do
			local rat = game.outcomes[ game.map[j][i] ][1]
			local val = rat.num / rat.den
			minVal = math.min(minVal, val)
        	maxVal = math.max(maxVal, val)
		end
	end
	for i,strat in ipairs(game.strats[1]) do
		local h_row = mw.html.create('tr'):node(
			mw.html.create('th'):attr('class', 'fg-p1 bg-white'):wikitext(strat)
				:node(mw.html.create('div'):attr('class', 'border-hack right border-black'))
		)
		for j,_ in ipairs(game.strats[2]) do
			local rat = game.outcomes[ game.map[j][i] ][1]
			local label = game.outcomes[ game.map[j][i] ][3]
			h_row:node(mw.html.create('td')
				:attr( 'class', G._main(rat.num / rat.den, minVal, maxVal) )
				:node(mw.html.create('abbr'):attr('title', label)
					:wikitext(string.format("%d", math.floor(rat.num / rat.den)))
				)
			)
		end
		h_payoffs:node(h_row)
	end
	
	--
	-- Create HTML for equilibrium, if it exists
	--
	local profile = string.match(gbt, "<profile[^>]->(.-)</profile[^>]->")
	local h_profile = nil
	if profile ~= nil then
		local r_profile = {}
		local split = mw.text.split(mw.text.trim(profile), ",")
		if #split ~= #game.strats[1] + #game.strats[2] then
			error("Profile has mismatched number of probabilities: "
				.. #split .. " vs " .. (#game.strats[1] + #game.strats[2]))
		end
		local n = 1
		for q = 1,2 do
			r_profile[q] = {}
			for i,v in ipairs(game.strats[q]) do
				r_profile[q][i] = p._ratParse(split[n])
				n = n+1
			end
		end
		
		local F = function (rat)
			if rat == nil then return "0" end
			-- Turn e.g. 15/-4 into -15/4
			local n,d = rat.num, rat.den
			if d < 0 then
				n = n * -1
				d = d * -1
			end
			return mw.getCurrentFrame():expandTemplate{
				title = "F", 
				args = { n, d }
			}
		end
		
		-- Calculate the payoff for p1 of this equilibrium
		--
		-- I believe you can get the payoff of any individual non-dominated
		-- outcome, since for an equilibrium they're all the same, but I'm not 
		-- entirely surely this is a guaranteed property.
		local payoff = 0
		for i = 1,#game.strats[1] do
			for j = 1,#game.strats[2] do
				local outcome = game.outcomes[ game.map[j][i] ][1]
				payoff = payoff + outcome * r_profile[2][j] * r_profile[1][i]
			end
		end
		
		-- Calculate the payoff of each strategy of this equilibrium
		--
		-- Used to display the payoff of the dominated options
		local s_payoff = {}
		for p = 1,2 do
			local q = (p == 1 and 2 or 1)
			s_payoff[p] = {}
			for i = 1,#game.strats[p] do
				local payoff = 0
				for j = 1,#game.strats[q] do
					local outcome = (p == 1)
						and game.outcomes[ game.map[j][i] ][1]
						or (game.outcomes[ game.map[i][j] ][1] * -1)
					payoff = payoff + outcome * r_profile[q][j]
				end
				s_payoff[p][i] = payoff
			end
		end
		--mw.logObject(s_payoff)
	
		h_profile = mw.html.create('div'):attr('class', 'profile')
		local h_elib = mw.html.create('div'):attr('class', 'equilibrium')
			:node( mw.html.create('p'):wikitext(
				"Nash equilibrium with payoff " .. F(payoff)) )
		local h_pair = mw.html.create('div'):attr('class', 'strategy-pair')
		local dominated_strats_exist = false
		local h_dominated = {}
		local n = 1
		for p = 1,2 do
			h_dominated[p] = mw.html.create('dl'):attr('class', 'strategy')
			local h_strat = mw.html.create('dl'):attr('class', 'strategy')
			for i,s in ipairs(game.strats[p]) do
				local prob = r_profile[p][i]
				local h_prob = mw.html.create('dd')
				local h_option = mw.html.create('div')
					:node(mw.html.create('dt'):attr('class', 'fg-p' .. p):wikitext(s))
					:node(h_prob)
				if prob.num == 0 then
					dominated_strats_exist = true
					h_prob:wikitext( F(s_payoff[p][i]) )
					h_dominated[p]:node(h_option)
				else
					h_prob:wikitext( F(prob) )
					h_strat:node(h_option)
				end
			end
			h_pair:node(h_strat)
		end
		h_profile:node(h_elib:node(h_pair))
		if dominated_strats_exist then
			local h_pair = mw.html.create('div'):attr('class', 'strategy-pair')
			for p = 1,2 do
				h_pair:node(h_dominated[p])
			end
			h_profile:node(mw.html.create('div'):attr('class', 'dominated')
				:node(mw.html.create('p'):wikitext("Payoff for dominated options"))
				:node(h_pair)
			)
		end
	end
	
	-- Put it together
	local header = mw.html.create('div'):attr('class', 'mixup-header')
		:node(mw.html.create('div'):attr('class', 'mixup-title')
			:wikitext(
				"[TEST] " ..
				"[[" .. title.fullText .. "]]" ..
				" ([" .. title:fullUrl() .. "?action=raw download])" ..
				" <sup>[[Template:Mixup#Explanation|What is this?]]</sup>"
			)
		)
	
	local body = mw.html.create('div')
		:attr('class', 'mixup-body mw-collapsible-content border-blue-30')
		:node(mw.html.create('div'):attr('class', 'payoffs'):node(h_payoffs))
		:node(h_profile)
	
	local ret = mw.html.create('div')
		:attr('class', 'mixup mw-collapsible border-blue-30')
		:node(header)
		
	if collapsed then
		ret:addClass('mw-collapsed')
	end
	
	if game.comment ~= "" then
		ret:node(mw.html.create('div')
			:attr('class', 'mixup-descr')
			:wikitext( mw.getCurrentFrame():preprocess( "\n" .. game.comment .. "\n" ) )
		)
	end
	
	ret:node(body)
	
	--mw.logObject(ret)
	return ret
end

return p