Module:Football event

From NationStates Sports Wiki
Jump to navigation Jump to search

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

-- Module:Football event
-- Implements the association-football match-event icon templates and provides the
-- goal / penalty auto-formatting for [[Module:Football box]].
--
-- Template entry points:
--   {{#invoke:Football event|goal}}        {{#invoke:Football event|goldengoal}}
--   {{#invoke:Football event|yellow}} / yel {{#invoke:Football event|sentoff}}
--   {{#invoke:Football event|subon}}        {{#invoke:Football event|suboff}}
-- Module entry point (called from another module):
--   require('Module:Football event').formatBox{
--       goals1 = ..., goals2 = ..., penalties1 = ..., penalties2 = ... }

local p = {}

-- --------------------------------------------------------------------------
-- shared data
-- --------------------------------------------------------------------------

local OG_LINK  = '([[Own goal#Association football|o.g.]])'
local PEN_LINK = '([[Penalty kick (association football)|pen.]])'
local YEL2_CAT = '[[Category:Yellow card template with second parameter]]'

local icon = {
	goal     = '[[File:Soccerball shade.svg|13px|Goal|link=|alt=]]',
	og       = '[[File:Soccerball-red with shade.svg|13px|Own goal|link=|alt=red-colored football]]',
	goldgoal = '[[File:Soccerball shade gold.svg|13px|Golden goal|link=|alt=gold-colored football]]',
	goldog   = '[[File:Soccerball-red with gold shade.svg|13px|Golden own goal|link=|alt=gold and red-colored football]]',
	pengoal  = '[[File:Soccerball shad check.svg|13px|Penalty scored|link=|alt=football with check mark]]',
	penmiss  = '[[File:Soccerball shade cross.svg|13px|Penalty missed|link=|alt=football with red X]]',
}

-- own-goal / penalty shorthand -> canonical modifier (lookup key is lower-cased)
local modMap = {
	['og'] = 'og', ['o.g.'] = 'og', ['o.g'] = 'og',
	['pen'] = 'pen', ['pen.'] = 'pen', ['p'] = 'pen', ['p.'] = 'pen',
	['pk'] = 'pen', ['p.k.'] = 'pen', ['p.k'] = 'pen',
}

-- penalty shoot-out keywords -> scored / missed (lookup key is lower-cased)
local penKW = {}
for _, w in ipairs({ 'goal', 'pengoal', 'score', 'scored' }) do penKW[w] = 'goal' end
for _, w in ipairs({ 'miss', 'penmiss', 'missed', 'saved' }) do penKW[w] = 'miss' end

-- stage abbreviations for the card templates: key -> {display, title}
local STAGES = {
	PRE  = { 'PRE',  'Pre-match' },
	HT   = { 'HT',   'Half-time' },
	AWET = { 'AWET', 'Awaiting extra time' },
	HTET = { 'HTET', 'Half-time in extra time' },
	PSO  = { 'PSO',  'Penalty shoot-out' },
	PK   = { 'PSO',  'Penalty shoot-out' },
	FT   = { 'FT',   'After full-time' },
}

-- every apostrophe / prime variant
local APOS = "[`'´ʹʼˈ‘’′]"

-- --------------------------------------------------------------------------
-- small helpers
-- --------------------------------------------------------------------------

local function notBlank(v)
	return v ~= nil and mw.text.trim(v) ~= ''
end

local function inMain()
	return mw.title.getCurrentTitle().namespace == 0
end

local function abbr(text, title)
	return '<abbr title="' .. title .. '">' .. text .. '</abbr>'
end

local function normApo(s)
	return (mw.ustring.gsub(s, APOS, "'"))
end

local function delink(s)
	s = mw.ustring.gsub(s, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
	return s
end

-- Format a minute and append the minute mark. Stoppage time: an apostrophe
-- typed before a "+" is dropped and the mark moved to the very end, with any
-- spaces around the "+" tidied away ("90' + 7" -> "90+7'").
local function fmtMin(s)
	s = normApo(mw.text.trim(s))
	s = mw.ustring.gsub(s, "'%s*(%+)", '%1') -- apostrophe before "+"
	s = mw.ustring.gsub(s, "[%s']*$", '')    -- trailing spaces / apostrophes
	s = mw.ustring.gsub(s, '%s*%+%s*', '+')  -- tidy spaces around "+"
	return s .. "'"
end

-- True when, after removing apostrophes / spaces, the value is only digits and
-- an optional "+" (i.e. a bare minute such as "45", "90+9", "90' + 7"). Used to
-- recover minutes that mis-keyed pipes pushed into an annotation slot.
local function isMinuteToken(v)
	local t = mw.ustring.gsub(normApo(v), "[%s']", '')
	return mw.ustring.match(t, '^%d+%+?%d*$') ~= nil
end

-- card time / stage label.
--   mode == 'plain' -> always a minute
--   mode == 'pso'   -> only PSO/PK/FT recognised (post-reset periods)
--   default         -> full stage list, otherwise a minute
local function stageLabel(v, mode)
	if mode == 'plain' then return fmtMin(v) end
	local up = mw.ustring.upper(mw.text.trim(v))
	if mode == 'pso' then
		if up == 'PSO' or up == 'PK' then return abbr('PSO', 'Penalty shoot-out') end
		if up == 'FT' then return abbr('FT', 'After full-time') end
		return ''
	end
	local s = STAGES[up]
	if s then return abbr(s[1], s[2]) end
	return fmtMin(v)
end

-- card icons (variable tooltip)
local function img(file, px, alt, tt)
	return '[[File:' .. file .. '|' .. px .. 'px|alt=' .. alt .. '|' .. tt .. '|link=]]'
end
local function yc(tt)  return img('Yellow card.svg',     10, 'Yellow card',     tt) end
local function rc(tt)  return img('Red card.svg',        10, 'Red card',        tt) end
local function yrc(tt) return img('Yellow-red card.svg', 12, 'Yellow-red card', tt) end

local function timed(ic, v, mode)
	if notBlank(v) then return ic .. '&nbsp;' .. stageLabel(v, mode) end
	return ic
end

-- --------------------------------------------------------------------------
-- goal rendering (shared by {{goal}}, {{golden goal}} and the box feature)
-- --------------------------------------------------------------------------

local function goalIcon(golden, og)
	if golden then return og and icon.goldog or icon.goldgoal end
	return og and icon.og or icon.goal
end

-- "<span><minute>[&nbsp;<annotation>]</span>"
local function goalSpan(minute, ann)
	local s, hasMin = '<span>', notBlank(minute)
	if hasMin then s = s .. fmtMin(minute) end
	if ann then
		if hasMin then s = s .. '&nbsp;' end
		s = s .. ann
	end
	return s .. '</span>'
end

-- raw annotation -> og flag, annotation text
local function goalAnnot(a)
	local m = modMap[mw.ustring.lower(mw.text.trim(delink(a)))]
	if m == 'og' then return true, nil end
	if m == 'pen' then return false, PEN_LINK end
	return false, '(' .. a .. ')'
end

-- Try to read a single positional token as a goal entry: either a bare minute
-- ("33", "90+9") or a minute followed by a RECOGNISED modifier, with optional
-- parentheses ("33 pen", "90+9 o.g.", "45 (pen.)"). Returns nil for anything
-- else (bare modifiers like "pen", or custom text), so those stay annotations
-- rather than being mistaken for minutes.
local function asMinuteEntry(v)
	if isMinuteToken(v) then
		return { minute = v, golden = false, og = false, ann = nil }
	end
	local minPart, modPart =
		mw.ustring.match(normApo(mw.text.trim(v)), '^([%d%+%s]-%d)%s*%(?%s*([%a%.]+)%s*%)?$')
	if minPart and isMinuteToken(minPart) then
		local m = modMap[mw.ustring.lower(modPart)]
		if m then
			return { minute = minPart, golden = false,
				og = (m == 'og'), ann = (m == 'pen') and PEN_LINK or nil }
		end
	end
	return nil
end

-- Render a player's goal cluster from a list of entries:
--   entry = { minute = raw, golden = bool, og = bool, ann = text|nil }
local function renderGoals(entries)
	local hasOG = false
	for _, e in ipairs(entries) do
		if e.og then hasOG = true break end
	end
	if hasOG then
		for _, e in ipairs(entries) do
			if not e.golden then e.og, e.ann = true, nil end
		end
	end
	local parts, lastG = {}, nil
	for i, e in ipairs(entries) do
		local ann = e.ann
		if e.og then
			local last = true
			for j = i + 1, #entries do
				if entries[j].golden == e.golden and entries[j].og then last = false break end
			end
			ann = last and OG_LINK or nil
		end
		local span = goalSpan(e.minute, ann)
		if e.golden ~= lastG then
			parts[#parts + 1] = goalIcon(e.golden, e.og) .. '&#32;' .. span
			lastG = e.golden
		else
			parts[#parts + 1] = span
		end
	end
	return table.concat(parts, ', ')
end

-- --------------------------------------------------------------------------
-- free-text parsing for the box feature
-- --------------------------------------------------------------------------

local function splitLines(s)
	s = mw.ustring.gsub(s, '<[Bb][Rr]%s*/?%s*>', '\n')
	local out = {}
	for _, l in ipairs(mw.text.split(s, '\n')) do
		l = mw.text.trim((mw.ustring.gsub(l, '^%s*%*%s*', '')))
		if l ~= '' then out[#out + 1] = l end
	end
	return out
end

local goldenPrefix = { 'golden%s+', 'gold%s+', 'g%.g%.%s*', 'gg%s*' }

-- player name (linked or unlinked) at the start of a goal line
local function extractPlayer(line)
	if mw.ustring.sub(line, 1, 2) == '[[' then
		local e = mw.ustring.find(line, '%]%]', 3)
		if e then
			return mw.ustring.sub(line, 1, e + 1), mw.text.trim(mw.ustring.sub(line, e + 2))
		end
	end
	-- unlinked: the name ends at the first digit or standalone golden keyword
	local pos = mw.ustring.find(line, '%d')
	local lower = mw.ustring.lower(line)
	for _, pat in ipairs({ '%s(golden)%s', '%s(gold)%s', '%s(gg)%s', '%s(gg)%d', '%s(g%.g%.)' }) do
		local s = mw.ustring.find(lower, pat)
		if s and (not pos or s + 1 < pos) then pos = s + 1 break end
	end
	if pos and pos > 1 then
		local name = mw.text.trim(mw.ustring.sub(line, 1, pos - 1))
		if mw.ustring.find(name, "[%-‐‑‒–—―−']$") then return line, '' end
		return name, mw.text.trim(mw.ustring.sub(line, pos))
	end
	return line, ''
end

-- one comma segment -> goal entry
local function parseSegment(seg)
	seg = mw.text.trim(seg)
	local golden, lower = false, mw.ustring.lower(seg)
	for _, gp in ipairs(goldenPrefix) do
		local s, e = mw.ustring.find(lower, '^' .. gp)
		if s then golden = true; seg = mw.text.trim(mw.ustring.sub(seg, e + 1)) break end
	end
	local minute, rest = mw.ustring.match(seg, "^(%d[%d%s%+']*)(.*)$")
	if not minute then return nil end          -- no leading minute -> not a goal
	local entry = { minute = mw.text.trim(minute), golden = golden, og = false, ann = nil }
	local mod = mw.ustring.match(rest, '^%s*%(%s*(.-)%s*%)%s*$') or rest -- "(o.g.)" or "o.g."
	mod = mw.text.trim(delink(mod))             -- a linked annotation reduces to its text
	if mod ~= '' then
		local m = modMap[mw.ustring.lower(mod)]
		if m == 'og' then entry.og = true
		elseif m == 'pen' then entry.ann = PEN_LINK
		else entry.unresolved = true end        -- an annotation we do not recognise
	end
	return entry
end

-- a comma-separated run of segments -> list of goal entries
local function segmentsToEntries(text)
	local entries = {}
	for _, seg in ipairs(mw.text.split(text, ',')) do
		if mw.text.trim(seg) ~= '' then
			local e = parseSegment(seg)
			if not e then return nil end
			entries[#entries + 1] = e
		end
	end
	return entries
end

local function joinLines(out, isList)
	if isList then
		for i, r in ipairs(out) do out[i] = '*' .. r end
		return table.concat(out, '\n')
	end
	return table.concat(out, '<br>')
end

-- returns: out (table of lines), isList, autoUsed
local function formatGoals(input)
	input = normApo(input)
	if mw.ustring.find(input, '[xX×]%d') then return nil end
	local isList = mw.ustring.match(mw.text.trim(input), '^%*') ~= nil
	local out = {}
	for _, line in ipairs(splitLines(input)) do
		local name, rest = extractPlayer(line)
		if rest == '' then return nil end
		local entries = segmentsToEntries(rest)
		if not entries or #entries == 0 then return nil end
		for _, e in ipairs(entries) do
			if e.unresolved then return nil end
		end
		out[#out + 1] = name .. ' <span class="fb-goal">' .. renderGoals(entries) .. '</span>'
	end
	if #out == 0 then return nil end
	return out, isList, true
end

-- returns: out (table of lines), isList, autoUsed
local function formatPenalties(input, isTeam2)
	local isList = mw.ustring.match(mw.text.trim(input), '^%*') ~= nil
	local out, auto = {}, false
	for _, line in ipairs(splitLines(input)) do
		local name, kw
		local ls, le = mw.ustring.find(line, '%[%[.-%]%]')
		if ls then
			name = mw.ustring.sub(line, ls, le)
			local before = mw.text.trim(mw.ustring.sub(line, 1, ls - 1))
			local after  = mw.text.trim(mw.ustring.sub(line, le + 1))
			kw = penKW[mw.ustring.lower(after)] or penKW[mw.ustring.lower(before)]
		else
			local lower = mw.ustring.lower(line)
			for word, res in pairs(penKW) do
				if mw.ustring.find(lower, '^' .. word .. '%s+') then
					kw, name = res, mw.text.trim(mw.ustring.sub(line, #word + 1)) break
				end
				local s = mw.ustring.find(lower, '%s+' .. word .. '$')
				if s then kw, name = res, mw.text.trim(mw.ustring.sub(line, 1, s - 1)) break end
			end
		end
		if name and kw then
			local ic = kw == 'goal' and icon.pengoal or icon.penmiss
			out[#out + 1] = isTeam2 and (ic .. ' ' .. name) or (name .. ' ' .. ic)
			auto = true
		else
			out[#out + 1] = line
		end
	end
	return out, isList, auto
end

-- --------------------------------------------------------------------------
-- argument helpers for the template entry points
-- --------------------------------------------------------------------------

local function getArgs(frame)
	local args = {}
	local parent = frame:getParent()
	if parent then for k, v in pairs(parent.args) do args[k] = v end end
	for k, v in pairs(frame.args) do args[k] = v end -- direct #invoke args win
	return args
end

local function maxIndex(args)
	local m = 0
	for k in pairs(args) do
		local n = tonumber(k)
		if n and n == math.floor(n) and n > m then m = n end
	end
	return m
end

-- --------------------------------------------------------------------------
-- template entry points
-- --------------------------------------------------------------------------

-- {{Goal}}
function p.goal(frame)
	local args = getArgs(frame)
	local maxn = maxIndex(args)

	-- gather the non-empty positional values in order
	local toks, hasComma = {}, false
	for i = 1, maxn do
		local v = args[i]
		if notBlank(v) then
			toks[#toks + 1] = v
			if mw.ustring.find(v, ',') then hasComma = true end
		end
	end

	local entries
	if hasComma then
		-- free-text dialect (same parser as the box feature)
		entries = segmentsToEntries(normApo(table.concat(toks, ', '))) or {}
	else
		-- pair dialect, tolerant of misplaced pipes
		entries = {}
		local current
		for _, v in ipairs(toks) do
			local e = asMinuteEntry(v)
			if e then
				current = e
				entries[#entries + 1] = e
			elseif current and current.ann == nil and not current.og then
				current.og, current.ann = goalAnnot(v) -- annotate the open minute
			elseif not current then
				current = { minute = '', golden = false, og = false, ann = nil }
				current.og, current.ann = goalAnnot(v)
				entries[#entries + 1] = current
			end
			-- a stray extra annotation after an already-annotated minute is dropped
		end
	end

	local inner = #entries > 0 and renderGoals(entries) or icon.goal
	return '<span class="fb-goal">' .. inner .. '</span>'
end

-- {{Golden goal}}
function p.goldengoal(frame)
	local args = getArgs(frame)
	local m, a = args[1], args[2]
	local og, ann = false, nil
	if notBlank(a) then og, ann = goalAnnot(a) end
	if notBlank(m) or notBlank(a) then
		return renderGoals({ { minute = m, golden = true, og = og, ann = ann } })
	end
	return goalIcon(true, og)
end

-- {{Yel}} -- yellow card
function p.yellow(frame)
	local args = getArgs(frame)
	local t1, t2 = args[1], args[2]
	local out = yc('Booked')
	if notBlank(t1) then
		out = out .. '<span style="vertical-align: middle;">&nbsp;' .. stageLabel(t1) .. '</span>'
	end
	local up = notBlank(t2) and mw.ustring.upper(mw.text.trim(t2)) or ''
	if up == 'PSO' or up == 'PK' or up == 'FT' then
		out = out .. '&nbsp;' .. yc('Booked immediately before/during/after penalty shoot-out (cards reset)')
			.. '<span style="vertical-align: middle;">&nbsp;' .. stageLabel(t2, 'pso') .. '</span>'
	end
	if inMain() and (notBlank(t2) or notBlank(args[3]) or notBlank(args[4])) then
		out = out .. YEL2_CAT
	end
	return out
end

-- {{Sent off}} -- param 1 is the number of bookings before dismissal
function p.sentoff(frame)
	local args = getArgs(frame)
	local mode = mw.text.trim(args[1] or '')
	local a2, a3, a4 = args[2], args[3], args[4]
	local body
	if mode == '1' then
		body = timed(yc('Booked'), a2) .. '&nbsp;' .. timed(rc('Sent off (straight red)'), a3)
	elseif mode == '1+1' then
		body = timed(yc('Booked'), a2)
			.. '&nbsp;' .. timed(yc('Booked immediately before/during/after penalty shoot-out (cards reset)'), a3, 'pso')
			.. '&nbsp;' .. timed(rc('Sent off (straight red) immediately before/during/after penalty shoot-out'), a4, 'pso')
	elseif mode == '2' then
		body = timed(yc('Booked'), a2) .. '&nbsp;' .. timed(yrc('Sent off (second booking)'), a3)
	elseif mode == '1+2' then
		body = timed(yc('Booked'), a2)
			.. '&nbsp;' .. timed(yc('Booked immediately before/during/after penalty shoot-out (cards reset)'), a3, 'pso')
			.. '&nbsp;' .. timed(yrc('Sent off (second booking) immediately before/during/after penalty shoot-out'), a4, 'pso')
	elseif mode == '3' then
		body = timed(yc('Booked'), a2, 'plain')
			.. '&nbsp;' .. timed(yc('Booked again'), a3, 'plain')
			.. '&nbsp;' .. timed(yrc('Sent off (third booking)'), a4, 'plain')
	else -- 0 / default: straight red
		body = timed(rc('Sent off (straight red)'), a2)
	end
	return '<span style="white-space:nowrap;">' .. body .. '</span>'
end

-- {{Subon}}
function p.subon(frame)
	local m = getArgs(frame)[1]
	local out = '[[File:Sub on.svg|10px|Substituted on|link=|alt=upward-facing green arrow]]'
	if notBlank(m) then
		out = out .. '&nbsp;<span style="vertical-align: middle;">' .. fmtMin(m) .. '</span>'
	end
	return out
end

-- {{Suboff}}
function p.suboff(frame)
	local args = getArgs(frame)
	local m = args[1]
	local out = '[[File:Sub off.svg|10px|Substituted off|link=|alt=downward-facing red arrow]]'
	if notBlank(m) then
		out = out .. '&nbsp;<span style="vertical-align: middle;">' .. fmtMin(m) .. '</span>'
	end
	if notBlank(args.con) or notBlank(args.concussion) then
		out = out .. '&nbsp;(' .. abbr('con.', 'Substituted off due to concussion') .. ')'
	end
	return out
end

p.yel = p.yellow

-- --------------------------------------------------------------------------
-- module entry point for Module:Football box
-- --------------------------------------------------------------------------

-- fields: { goals1, goals2, penalties1, penalties2 }
-- Returns a table of formatted fields plus `auto` = true when any icon was generated.
function p.formatBox(fields)
	local res, auto = {}, false
	local function run(key, fn, ...)
		local v = fields[key]
		if v ~= nil and v ~= '' then
			local out, isList, a = fn(v, ...)
			if out ~= nil then
				res[key] = joinLines(out, isList)
				auto = auto or a
			end
		end
	end
	run('goals1', formatGoals)
	run('goals2', formatGoals)
	run('penalties1', formatPenalties, false)
	run('penalties2', formatPenalties, true)
	res.auto = auto
	return res
end

return p