Module:Sports table/auto/sandbox
| This is the module sandbox page for Module:Sports table/auto (diff). |
| This module is rated as beta. It is considered ready for widespread use, but as it is still relatively new, it should be applied with some caution to ensure results are as expected. |
This is a supporting module of Module:Sports table which helps automatically generate the standings of a sports competition based on the match results.
Purpose
[edit]Normally, Module:Sports table is generated by entering in the record for all teams (for example |win_TTT=, |draw_TTT=, |loss_TTT=, |gf_TTT=, |ga_TTT=) as well as the positions of all teams (|teamN=TTT or |team_order=AAA, BBB, TTT, ...). Results for specific matches can also be shown alongside the standings (example), which relies on Module:Sports results; that module can also be called directly for standalone usage.
When using the "auto" mode, the match results themselves are used to generate the record for each team (the parameters that set the record of each team are ignored). Then, using tiebreakers specified by the user, the "auto" mode will determine the exact ranking of teams. Thus, only the score of a match needs to be added to update the standings. Tiebreakers that cannot be inferred by the match scores (like disciplinary points or coefficients) are not supported; these teams must have their positions manually set (otherwise they will be displayed by the module as tied).
Tables using the prior format are unaffected; this feature must be explicitly called using |auto_generate_standings=yes. The |auto_generate_standings= feature is currently supported by |style=WDL, |style=WL, |style=WDLHA and |style=WLHA
Features
[edit]Ranking criteria
[edit]The ranking criteria are passed via the |auto_generate_criteria= parameter, comma-separated. If the parameter is omitted, the default is pts, gd, gf.
By default, values sort from highest to lowest. A minus sign in front of an identifier sorts from lowest to highest.
| Identifier | Description |
|---|---|
| pts | points |
| w | wins |
| d | draws |
| l | losses |
| gf | goals for |
| ga | goals against |
| gd | goal difference |
| gr | goal ratio |
| gp | goal percentage |
| ap | against percentage |
| pp | point percentage |
| gpm | goals per match |
| wp | win percentage |
| perc | percentage |
| ppg | points per game |
Prefix any criterion with h or a to count only that team's home or away record for the statistic, e.g. |ranking_criteria=hpts (home points), |ranking_criteria=agf (away goals for), |ranking_criteria=awp (away win percentage). Without the prefix, the full/overall record is used.
The following prefixes are also available:
- h2h_: the same statistic counted only from the matches played between the teams that are still tied (e.g. h2h_pts, h2h_gd, h2h_gf, h2h_hpts).
- By default, this is calculated even if all the head-to-head matches between the tied teams have not yet been played. To skip over the head-to-head criteria until all relevant matches have been played, set
|h2h_after_complete=yes. To stop before the head-to-head criteria until all relevant matches have been played, set|h2h_after_complete=freeze.
- By default, this is calculated even if all the head-to-head matches between the tied teams have not yet been played. To skip over the head-to-head criteria until all relevant matches have been played, set
- opp_: the combined full-season record of that statistic for every opponent a team has faced (e.g. opp_pts, opp_gd, opp_gf, opp_agpm). Note that an opponent met twice counts twice.
The home/away modifier and the scope prefix can be combined; the scope prefix comes first, e.g. |ranking_criteria=h2h_hpts is head-to-head home points, and |ranking_criteria=opp_agd is the away goal difference aggregated across a team's opponents.
Teams with no value for a criterion (e.g. a percentage with no matches played) sort below teams that have a value. A goal ratio with goals scored and none conceded counts as the highest possible value.
Note that the table will not generate tiebreaker footnotes; this still must be manually set with |hth_TTT=. Nor will it set the classification rules text displayed to readers; this must be defined with |class_rules=.
- Recursive criteria
Certain competitions recursively apply head-to-head criteria. To support this, this module applies criteria inside parentheses as a group, and the group starts again from the beginning for any set of teams that remain level upon reaching the end of the group. Therefore, if the group separates some of the tied teams but leaves others level, the head-to-head standings are recalculated from just the matches played between the teams that remain tied. This repeats until no further progress is made, at which point the criteria after the parentheses are applied.
- Criteria used only at the end of the season
A full stop can be used to delineate between criteria that apply during the season, and criteria that only apply after the completion of the competition when |update=complete. For example, |auto_generate_criteria=pts, gd, gf. h2h_pts, h2h_gd, h2h_gf means that the head-to-head criteria only apply after the completion of the season.
Order and position of teams
[edit]If, after applying the tiebreaker criteria, two or more teams remain level, they will share a position in the table. If |default_order=AAA, BBB, CCC, ... is provided, it will determine the order in which they appear. Codes left out are added at the end in alphabetical order by team name; if the parameter is omitted, all teams are ordered that way. The name used for sorting is the plain text of |name_AAA=; if this is omitted, or two teams share the same name, the code itself is used to determine the order.
- Override the calculated order
The order of teams that the module generates can be overridden in two ways:
|team_order=AAA, BBB, CCC, ...: determines the entire order of the standings|teamN=AAA: moves team AAA to position N- This option allows for manually setting the position of some teams without listing them all. However, every team that is displaced has to be given a position of its own. Setting |team3=CCC alone does not work, because whatever team was third has nowhere to go. If the order cannot be resolved, an error will be displayed when previewing the page, and the module will default to the auto-calculated order.
If both are set, |team_order= takes precedence.
- Value in the position column
After applying all criteria, teams that remain level share the same position number. This can be disabled using |show_tied_positions=no.
A tied group loses its shared number if |teamN= is used to reorder any team(s) in it, though other tied groups in the same table are unaffected. The parameter |team_order= removes shared numbering for the entire table.
As with the standard use of Module:Sports table, |pos_TTT= will override the text in the position column for a single team, and does not change where that team appears in the table.
Handling of match results
[edit]As with the standard use of Module:Sports table, the match results can be displayed in the output by relying on Module:Sports results. The following options are available:
| Parameter value | Description |
|---|---|
|show_matches=no or omitted
|
standings only |
|show_matches=yes
|
standings with the results grid built into the same table |
|show_matches=only
|
the results grid on its own, no standings |
|show_matches=below
|
standings, then a level-two heading, then the results grid |
|show_matches=below_levelN
|
standings, then a heading, then the results grid with a level N heading |
Setting the parameter |show_matches=only while using transclulsion allows for the results grid to be output in that instance, while letting the standings table to be output in other transclusions.
For |show_matches=below and |show_matches=below_levelN, the parameter |below_results_header= can be used to set a custom heading (default is "Results").
The order of teams in the results grid (for both |show_matches=only and |show_matches=below/|show_matches=below_levelN) is determined by |default_order=. If not set, the order is then alphabetical by the team's plain text name; if this is omitted, or two teams share the same name, the code itself is used to determine the order.
- Conflicting parameters
As the parameters |title= and |team_header= conflict between Module:Sports series and Module:Sports results, the following parameters are used when |show_matches=only or |show_matches=below:
|matches_title=: caption for the results grid|matches_team_header=: text for the corner cell, replacing "Home \ Away"|matches_source=: an alternate source for the results grid, overriding|source=(the default for the sports table and result outputs).|matches_note_TTT=: adds a note for team TTT in the results grid;|matches_note_TTT=note_TTTcan be used to explicitly reuse that team's note from the standings table.|matches_team_notes=yes: passes the notes of all teams (|note_TTT=) used in the standings table to the results grid. By default, these notes only appears in the standings table.
Usage
[edit]Basic usage
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf, h2h_agf), gd, gf, agf
|update=complete
|source=UEFA
|name_BIH={{fb|BIH}} |short_BIH={{fbicon|BIH}}
|name_BEL={{fb|BEL}} |short_BEL={{fbicon|BEL}}
|name_ISR={{fb|ISR}} |short_ISR={{fbicon|ISR}}
|name_WAL={{fb|WAL}} |short_WAL={{fbicon|WAL}}
|name_CYP={{fb|CYP}} |short_CYP={{fbicon|CYP}}
|name_AND={{fb|AND}} |short_AND={{fbicon|AND}}
|result1=Q |result2=Q |result3=PO
|res_col_header=Q
|col_Q=green1 |text_Q=Qualify for [[UEFA Euro 2016|final tournament]]
|col_PO=blue1 |text_PO=Advance to [[UEFA Euro 2016 qualifying play-offs|play-offs]]
|match_BIH_BEL=1–1
|match_BIH_ISR=3–1
|match_BIH_WAL=2–0
|match_BIH_CYP=1–2
|match_BIH_AND=3–0
|match_BEL_BIH=3–1
|match_BEL_ISR=3–1
|match_BEL_WAL=0–0
|match_BEL_CYP=5–0
|match_BEL_AND=6–0
|match_ISR_BIH=3–0
|match_ISR_BEL=0–1
|match_ISR_WAL=0–3
|match_ISR_CYP=1–2
|match_ISR_AND=4–0
|match_WAL_BIH=0–0
|match_WAL_BEL=1–0
|match_WAL_ISR=0–0
|match_WAL_CYP=2–1
|match_WAL_AND=2–0
|match_CYP_BIH=2–3
|match_CYP_BEL=0–1
|match_CYP_ISR=1–2
|match_CYP_WAL=0–1
|match_CYP_AND=5–0
|match_AND_BIH=0–3
|match_AND_BEL=1–4
|match_AND_ISR=1–4
|match_AND_WAL=1–2
|match_AND_CYP=1–3
}}
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
Source: UEFA
|
Example with position swap
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf, h2h_agf), gd, gf, agf
|update=complete
|source=UEFA
|team1=BIH |team3=BEL
|name_BIH={{fb|BIH}} |short_BIH={{fbicon|BIH}}
|name_BEL={{fb|BEL}} |short_BEL={{fbicon|BEL}}
|name_ISR={{fb|ISR}} |short_ISR={{fbicon|ISR}}
|name_WAL={{fb|WAL}} |short_WAL={{fbicon|WAL}}
|name_CYP={{fb|CYP}} |short_CYP={{fbicon|CYP}}
|name_AND={{fb|AND}} |short_AND={{fbicon|AND}}
|result1=Q |result2=Q |result3=PO
|res_col_header=Q
|col_Q=green1 |text_Q=Qualify for [[UEFA Euro 2016|final tournament]]
|col_PO=blue1 |text_PO=Advance to [[UEFA Euro 2016 qualifying play-offs|play-offs]]
|match_BIH_BEL=1–1
|match_BIH_ISR=3–1
|match_BIH_WAL=2–0
|match_BIH_CYP=1–2
|match_BIH_AND=3–0
|match_BEL_BIH=3–1
|match_BEL_ISR=3–1
|match_BEL_WAL=0–0
|match_BEL_CYP=5–0
|match_BEL_AND=6–0
|match_ISR_BIH=3–0
|match_ISR_BEL=0–1
|match_ISR_WAL=0–3
|match_ISR_CYP=1–2
|match_ISR_AND=4–0
|match_WAL_BIH=0–0
|match_WAL_BEL=1–0
|match_WAL_ISR=0–0
|match_WAL_CYP=2–1
|match_WAL_AND=2–0
|match_CYP_BIH=2–3
|match_CYP_BEL=0–1
|match_CYP_ISR=1–2
|match_CYP_WAL=0–1
|match_CYP_AND=5–0
|match_AND_BIH=0–3
|match_AND_BEL=1–4
|match_AND_ISR=1–4
|match_AND_WAL=1–2
|match_AND_CYP=1–3
}}
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
Source: UEFA
|
Example with default_order
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf), gd, gf, agf, w, aw
|short_style=noflag
|source=UEFA
|update=future |start_date=20 September 2023
|default_order=BAY, MUN, CPH, GAL
|name_BAY={{fbaicon|GER}} [[FC Bayern Munich|Bayern Munich]]
|name_MUN={{fbaicon|ENG}} [[Manchester United F.C.|Manchester United]]
|name_CPH={{fbaicon|DEN}} [[F.C. Copenhagen|Copenhagen]]
|name_GAL={{fbaicon|TUR}} [[Galatasaray S.K. (football)|Galatasaray]]
|res_col_header=Q
|result1=KO |result2=KO |result3=EL
|col_KO=green1 |text_KO=Advance to [[2023–24 UEFA Champions League knockout phase|knockout phase]]
|col_EL=blue1 |text_EL=Transfer to [[2023–24 UEFA Europa League knockout phase#Knockout round play-offs|Europa League]]
}}
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
First match(es) will be played: 20 September 2023. Source: UEFA
|
Example with default_order and show_tied_positions=no
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf), gd, gf, agf, w, aw
|show_tied_positions=no
|short_style=noflag
|source=UEFA
|update=future |start_date=20 September 2023
|default_order=BAY, MUN, CPH, GAL
|name_BAY={{fbaicon|GER}} [[FC Bayern Munich|Bayern Munich]]
|name_MUN={{fbaicon|ENG}} [[Manchester United F.C.|Manchester United]]
|name_CPH={{fbaicon|DEN}} [[F.C. Copenhagen|Copenhagen]]
|name_GAL={{fbaicon|TUR}} [[Galatasaray S.K. (football)|Galatasaray]]
|res_col_header=Q
|result1=KO |result2=KO |result3=EL
|col_KO=green1 |text_KO=Advance to [[2023–24 UEFA Champions League knockout phase|knockout phase]]
|col_EL=blue1 |text_EL=Transfer to [[2023–24 UEFA Europa League knockout phase#Knockout round play-offs|Europa League]]
}}
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
First match(es) will be played: 20 September 2023. Source: UEFA
|
Example with show_matches=yes
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf, h2h_agf), gd, gf, agf
|update=complete
|source=UEFA
|show_matches=yes
|name_ESP={{fb|ESP}} |short_ESP={{fbicon|ESP}}
|name_UKR={{fb|UKR}} |short_UKR={{fbicon|UKR}}
|name_SVK={{fb|SVK}} |short_SVK={{fbicon|SVK}}
|name_BLR={{fb|BLR}} |short_BLR={{fbicon|BLR}}
|name_MKD={{fb|MKD|name=Macedonia}} |short_MKD={{fbicon|MKD}}
|name_LUX={{fb|LUX}} |short_LUX={{fbicon|LUX}}
|hth_LUX=Tied on head-to-head points (3) and head-to-head goal difference (0). Head-to-head away goals: Luxembourg 2, Macedonia 0. |hth_MKD=LUX
|result1=Q |result2=Q |result3=PO
|res_col_header=Q
|col_Q=green1 |text_Q=Qualify for [[UEFA Euro 2016|final tournament]]
|col_PO=blue1 |text_PO=Advance to [[UEFA Euro 2016 qualifying play-offs|play-offs]]
|match_ESP_UKR=1–0
|match_ESP_SVK=2–0
|match_ESP_BLR=3–0
|match_ESP_MKD=5–1
|match_ESP_LUX=4–0
|match_UKR_ESP=0–1
|match_UKR_SVK=0–1
|match_UKR_BLR=3–1
|match_UKR_MKD=1–0
|match_UKR_LUX=3–0
|match_SVK_ESP=2–1
|match_SVK_UKR=0–0
|match_SVK_BLR=0–1
|match_SVK_MKD=2–1
|match_SVK_LUX=3–0
|match_BLR_ESP=0–1
|match_BLR_UKR=0–2
|match_BLR_SVK=1–3
|match_BLR_MKD=0–0
|match_BLR_LUX=2–0
|match_MKD_ESP=0–1
|match_MKD_UKR=0–2
|match_MKD_SVK=0–2
|match_MKD_BLR=1–2
|match_MKD_LUX=3–2
|match_LUX_ESP=0–4
|match_LUX_UKR=0–3
|match_LUX_SVK=2–4
|match_LUX_BLR=1–1
|match_LUX_MKD=1–0
}}
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
Source: UEFA
Notes: |
Example with show_matches=only
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=pts, (h2h_pts, h2h_gd, h2h_gf, h2h_agf), gd, gf, agf
|update=complete
|source=UEFA
|show_matches=only
|name_ESP={{fb|ESP}} |short_ESP={{fbicon|ESP}}
|name_UKR={{fb|UKR}} |short_UKR={{fbicon|UKR}}
|name_SVK={{fb|SVK}} |short_SVK={{fbicon|SVK}}
|name_BLR={{fb|BLR}} |short_BLR={{fbicon|BLR}}
|name_MKD={{fb|MKD|name=Macedonia}} |short_MKD={{fbicon|MKD}}
|name_LUX={{fb|LUX}} |short_LUX={{fbicon|LUX}}
|hth_LUX=Tied on head-to-head points (3) and head-to-head goal difference (0). Head-to-head away goals: Luxembourg 2, Macedonia 0. |hth_MKD=LUX
|result1=Q |result2=Q |result3=PO
|res_col_header=Q
|col_Q=green1 |text_Q=Qualify for [[UEFA Euro 2016|final tournament]]
|col_PO=blue1 |text_PO=Advance to [[UEFA Euro 2016 qualifying play-offs|play-offs]]
|match_ESP_UKR=1–0
|match_ESP_SVK=2–0
|match_ESP_BLR=3–0
|match_ESP_MKD=5–1
|match_ESP_LUX=4–0
|match_UKR_ESP=0–1
|match_UKR_SVK=0–1
|match_UKR_BLR=3–1
|match_UKR_MKD=1–0
|match_UKR_LUX=3–0
|match_SVK_ESP=2–1
|match_SVK_UKR=0–0
|match_SVK_BLR=0–1
|match_SVK_MKD=2–1
|match_SVK_LUX=3–0
|match_BLR_ESP=0–1
|match_BLR_UKR=0–2
|match_BLR_SVK=1–3
|match_BLR_MKD=0–0
|match_BLR_LUX=2–0
|match_MKD_ESP=0–1
|match_MKD_UKR=0–2
|match_MKD_SVK=0–2
|match_MKD_BLR=1–2
|match_MKD_LUX=3–2
|match_LUX_ESP=0–4
|match_LUX_UKR=0–3
|match_LUX_SVK=2–4
|match_LUX_BLR=1–1
|match_LUX_MKD=1–0
}}
|
| What it looks like |
Example ranked lowest to highest
[edit]| What you type | {{#invoke:Sports table|main|style=WDL
|auto_generate_standings=yes
|ranking_criteria=-pts, -gd, -gf
|update=complete
|source=UEFA
|name_BIH={{fb|BIH}} |short_BIH={{fbicon|BIH}}
|name_BEL={{fb|BEL}} |short_BEL={{fbicon|BEL}}
|name_ISR={{fb|ISR}} |short_ISR={{fbicon|ISR}}
|name_WAL={{fb|WAL}} |short_WAL={{fbicon|WAL}}
|name_CYP={{fb|CYP}} |short_CYP={{fbicon|CYP}}
|name_AND={{fb|AND}} |short_AND={{fbicon|AND}}
|result1=Q |result2=Q |result3=PO
|res_col_header=Q
|col_Q=green1 |text_Q=Qualify for [[UEFA Euro 2016|final tournament]]
|col_PO=blue1 |text_PO=Advance to [[UEFA Euro 2016 qualifying play-offs|play-offs]]
|match_BIH_BEL=1–1
|match_BIH_ISR=3–1
|match_BIH_WAL=2–0
|match_BIH_CYP=1–2
|match_BIH_AND=3–0
|match_BEL_BIH=3–1
|match_BEL_ISR=3–1
|match_BEL_WAL=0–0
|match_BEL_CYP=5–0
|match_BEL_AND=6–0
|match_ISR_BIH=3–0
|match_ISR_BEL=0–1
|match_ISR_WAL=0–3
|match_ISR_CYP=1–2
|match_ISR_AND=4–0
|match_WAL_BIH=0–0
|match_WAL_BEL=1–0
|match_WAL_ISR=0–0
|match_WAL_CYP=2–1
|match_WAL_AND=2–0
|match_CYP_BIH=2–3
|match_CYP_BEL=0–1
|match_CYP_ISR=1–2
|match_CYP_WAL=0–1
|match_CYP_AND=5–0
|match_AND_BIH=0–3
|match_AND_BEL=1–4
|match_AND_ISR=1–4
|match_AND_WAL=1–2
|match_AND_CYP=1–3
}}
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| What it looks like |
Source: UEFA
|
See also
[edit]-- Builds standings from match results for |auto_generate_standings=yes.
-- Reads match cells, calculates records, ranks teams, and writes the results
-- back for Module:Sports table. See the documentation for criteria.
require('strict')
local pa = {}
local yesno = require('Module:Yesno')
-- Fields in each statistics record.
local FIELDS = {'pld', 'w', 'd', 'l', 'gf', 'ga',
'hpld', 'hw', 'hd', 'hl', 'hgf', 'hga',
'apld', 'aw', 'ad', 'al', 'agf', 'aga'}
-- Score separators normalized to hyphens before matching.
local DASHES = {'–', '—', '−', '‒', '―', '‐', '‑'}
local DASH = '%-+'
-- Limit displayed errors for badly broken tables.
local MAX_ERRORS = 25
-- Limit recursive tiebreak processing.
local MAX_RECURSION = 25
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
local function trim(s)
return mw.text.trim(tostring(s or ''))
end
local function add_error(errors, msg)
if #errors < MAX_ERRORS then
errors[#errors + 1] = msg
elseif #errors == MAX_ERRORS then
errors[#errors + 1] = 'further problems were found but are not listed'
end
end
--------------------------------------------------------------------------------
-- Match-cell parsing
--------------------------------------------------------------------------------
-- Remove common markup from a match cell before parsing its score.
local function clean_cell(raw)
local s = tostring(raw or '')
-- Strip reference markers.
s = s:gsub('\127[^\127]*\127', '')
-- Remove superscripts; retain other tag contents.
s = s:gsub('<[Ss][Uu][Pp].->.-</[Ss][Uu][Pp]>', '')
s = s:gsub('</?%w+[^<>]*>', '')
-- Keep wikilink display text.
s = s:gsub('%[%[[^%[%]|]*|([^%[%]|]*)%]%]', '%1')
s = s:gsub('%[%[([^%[%]|]*)%]%]', '%1')
-- Keep external-link display text.
s = s:gsub('%[%S+%s+([^%[%]]*)%]', '%1')
-- Normalize entities.
s = s:gsub('&[Nn][Dd][Aa][Ss][Hh];', '-')
s = s:gsub('&[Mm][Dd][Aa][Ss][Hh];', '-')
s = s:gsub('−', '-')
s = s:gsub(' ', ' ')
-- Normalize dash characters.
for _, d in ipairs(DASHES) do
s = s:gsub(d, '-')
end
return trim(s)
end
-- Returns scores and whether an unparseable value resembles a score.
local function parse_score(raw)
local s = clean_cell(raw)
if s == '' or s:lower() == 'null' then
return nil, nil, false
end
local h, a = s:match('^([%d%.]+)%s*' .. DASH .. '%s*([%d%.]+)')
h, a = tonumber(h or ''), tonumber(a or '')
if h and a then
return h, a, false
end
-- Flag score-like but unparseable values.
local looks = s:match('[%d%.]+%s*' .. DASH .. '%s*[%d%.]+') ~= nil
return nil, nil, looks
end
-- Split match parameter suffixes, preferring known codes with underscores.
local function split_pair(rest, codes)
local cands = {}
local i = rest:find('_', 1, true)
while i do
local h, a = rest:sub(1, i - 1), rest:sub(i + 1)
if h ~= '' and a ~= '' then
cands[#cands + 1] = {h, a}
end
i = rest:find('_', i + 1, true)
end
if #cands == 0 then return nil end
for _, c in ipairs(cands) do
if codes[c[1]] and codes[c[2]] then return c[1], c[2] end
end
for _, c in ipairs(cands) do
if codes[c[1]] then return c[1], c[2] end
end
for _, c in ipairs(cands) do
if codes[c[2]] then return c[1], c[2] end
end
return cands[#cands][1], cands[#cands][2]
end
-- Return match parameters in a stable order.
local function match_keys(Args)
local keys = {}
for k, v in pairs(Args) do
if type(k) == 'string' and v ~= nil then
-- Check neutral matches first.
local num, rest = k:match('^matchn(%d*)_(.+)$')
local neutral = true
if not rest then
num, rest = k:match('^match(%d*)_(.+)$')
neutral = false
end
if rest and not rest:match('_note$') then
keys[#keys + 1] = {key = k, rest = rest, neutral = neutral,
num = tonumber(num) or 1, numbered = (num ~= '')}
end
end
end
table.sort(keys, function(x, y) return x.key < y.key end)
return keys
end
--------------------------------------------------------------------------------
-- Team list
--------------------------------------------------------------------------------
-- Convert a team name to plain text for sorting and link anchors.
-- Keep this in sync with clean_team_name in Module:Sports results.
local function replace_link(m)
local pipe_pos = m:find('|')
if pipe_pos then
return m:sub(pipe_pos + 1, -3) -- text after the '|'
else
return m:sub(3, -3) -- text between the brackets
end
end
local function plain_name(str, default)
if str and str ~= '' then
str = str:gsub('<sup.->.-</sup>', '')
str = str:gsub('</?%w+[^>]*>', '')
str = str:gsub('\127%\'"`UNIQ.-QINU`"%\'\127', '')
str = str:gsub('%[%[[Ff]ile:[^%]]+%]%]', '')
str = str:gsub('%[%[[Ii]mage:[^%]]+%]%]', '')
str = str:gsub('%[%[.-%]%]', replace_link)
str = str:gsub('%s* %s*', '')
str = str:gsub("'''+", '')
str = str:gsub("''", '')
str = str:match('^%s*(.-)%s*$') -- trim
return (str ~= '' and str) or default
end
return default
end
local function name_sorter(Args)
local key = {}
local function keyof(c)
if key[c] == nil then
key[c] = string.lower(plain_name(Args['name_' .. c], c))
end
return key[c]
end
return function(x, y)
local kx, ky = keyof(x), keyof(y)
if kx == ky then return x < y end
return kx < ky
end
end
-- Read outside opponents, which are excluded from the standings and matches.
function pa.external(Args)
local ext = {}
for _, c in ipairs(mw.text.split(trim(Args['external_teams'] or ''), '%s*[;,]%s*')) do
if c ~= '' then ext[c] = true end
end
return ext
end
-- Build the roster from names and matches, excluding outside opponents.
-- The code set retains outsiders to split match names with underscores.
function pa.roster(Args)
local codes, list, appears = {}, {}, {}
local ext = pa.external(Args)
for k, _ in pairs(Args) do
if type(k) == 'string' then
local c = k:match('^name_(.+)$') or k:match('^schedule_(.+)$')
if c and c ~= '' then codes[c] = true end
end
end
for _, mk in ipairs(match_keys(Args)) do
local h, a = split_pair(mk.rest, codes)
if h then
codes[h] = true
codes[a] = true
-- Outside matches do not count as league fixtures.
if not (ext[h] or ext[a]) then
appears[h] = (appears[h] or 0) + 1
if a ~= h then appears[a] = (appears[a] or 0) + 1 end
end
end
end
for c in pairs(codes) do
if not ext[c] then list[#list + 1] = c end
end
table.sort(list, name_sorter(Args))
return list, codes, appears
end
-- Warn about likely mistyped team codes without rejecting valid sparse input.
local function check_orphans(Args, teams, appears, errors)
local n_named = 0
local unnamed = {}
for _, c in ipairs(teams) do
if Args['name_' .. c] ~= nil then
n_named = n_named + 1
else
unnamed[#unnamed + 1] = c
end
end
-- Unnamed codes are suspicious only when other teams have names.
if n_named > 0 and #unnamed > 0 then
add_error(errors, 'unnamed teams = ' .. table.concat(unnamed, ', ')
.. ' (found in a match parameter but given no |name_TTT=)')
end
-- Ignore idle teams when the schedule is largely unfilled.
local total, idle = 0, {}
for _, c in ipairs(teams) do
total = total + (appears[c] or 0)
if (appears[c] or 0) == 0 then idle[#idle + 1] = c end
end
if total > 0 and #idle > 0 and (#idle * 3) <= #teams then
add_error(errors, 'teams with no matches = ' .. table.concat(idle, ', ')
.. ' (no match parameter mentions them; check for a mistyped team code)')
end
end
-- Base order: |default_order= first, then remaining codes alphabetically.
function pa.base_order(Args, teams, codes, errors)
local order, seen = {}, {}
local raw = Args['default_order']
if raw and trim(raw) ~= '' then
for _, tname in ipairs(mw.text.split(raw, '%s*[;,]%s*')) do
tname = trim(tname)
if tname ~= '' then
if not codes[tname] then
add_error(errors, 'the team "' .. tname .. '" given in |default_order= is not in the table')
elseif seen[tname] then
add_error(errors, 'the team "' .. tname .. '" is given more than once in |default_order=')
else
seen[tname] = true
order[#order + 1] = tname
end
end
end
end
-- teams is already sorted alphabetically.
for _, c in ipairs(teams) do
if not seen[c] then order[#order + 1] = c end
end
return order
end
--------------------------------------------------------------------------------
-- Statistics
--------------------------------------------------------------------------------
local function new_stats()
local st = {}
for _, f in ipairs(FIELDS) do st[f] = 0 end
return st
end
-- Neutral matches affect overall records, not home/away records.
-- side is 'home', 'away', or 'neutral'.
local function add_result(st, gf, ga, side)
st.pld = st.pld + 1
st.gf = st.gf + gf
st.ga = st.ga + ga
local w, d, l = 0, 0, 0
if gf > ga then
w = 1
elseif gf < ga then
l = 1
else
d = 1
end
st.w, st.d, st.l = st.w + w, st.d + d, st.l + l
if side == 'home' then
st.hpld = st.hpld + 1
st.hgf = st.hgf + gf
st.hga = st.hga + ga
st.hw, st.hd, st.hl = st.hw + w, st.hd + d, st.hl + l
elseif side == 'away' then
st.apld = st.apld + 1
st.agf = st.agf + gf
st.aga = st.aga + ga
st.aw, st.ad, st.al = st.aw + w, st.ad + d, st.al + l
end
end
-- Build played matches; the first code is the home team.
function pa.matches(Args, codes, errors)
local out = {}
local legs = tonumber(Args['legs']) or 1
local fmt = string.lower(trim(Args['matches_format']))
local schedule = (fmt == 'schedule' or fmt == 'series' or fmt == 'series_split')
-- Neutral matches cannot appear in a results grid.
local sm = string.lower(trim(Args['show_matches']))
local shows_grid = (fmt == '' or fmt == 'grid')
and sm ~= '' and sm ~= 'no' and sm ~= 'n' and sm ~= 'false' and sm ~= 'f'
and sm ~= 'off' and sm ~= '0'
local seen = {}
local ext = pa.external(Args)
for _, mk in ipairs(match_keys(Args)) do
local h, a = split_pair(mk.rest, codes)
local hs, as, broken = parse_score(Args[mk.key])
-- Skip matches against listed outside opponents.
local outside = h and (ext[h] or ext[a]) and true or false
-- Count only matches shown by grid mode; schedules number freely.
local displayed = true
if not schedule and not mk.neutral then
displayed = (legs == 1) and not mk.numbered or (mk.numbered and mk.num <= legs)
end
-- Neutral fixtures are unordered.
local pair_id
if mk.neutral and h then
local x, y = h, a
if y < x then x, y = y, x end
pair_id = 'n' .. mk.num .. '|' .. x .. '|' .. y
else
pair_id = mk.num .. '|' .. tostring(h) .. '|' .. tostring(a)
end
if not h then
add_error(errors, 'could not read two team codes from |' .. mk.key .. '=')
elseif outside then
-- Outside fixture.
elseif h == a then
add_error(errors, '|' .. mk.key .. '= is a match of a team against itself')
elseif seen[pair_id] then
-- Catch reversed duplicate neutral fixtures.
add_error(errors, '|' .. mk.key .. '= repeats a match already given by |' .. seen[pair_id]
.. '=' .. (mk.neutral and ' (a neutral match is the same match either way round)' or ''))
elseif not displayed then
add_error(errors, '|' .. mk.key .. '= is not shown in the results grid with |legs='
.. legs .. ', so it has not been counted; use |'
.. ((legs == 1) and ('match_' .. mk.rest) or ('match<1-' .. legs .. '>_' .. mk.rest)) .. '=')
elseif broken then
add_error(errors, 'could not read the score in |' .. mk.key .. '=' .. trim(Args[mk.key]))
else
if mk.neutral and shows_grid then
add_error(errors, '|' .. mk.key .. '= is a neutral-venue match, which the results grid '
.. 'cannot show; it has still been counted in the standings')
end
seen[pair_id] = mk.key
if hs then
out[#out + 1] = {home = h, away = a, hs = hs, as = as, neutral = mk.neutral}
end
end
end
return out
end
-- Build statistics for these teams only (used for head-to-head criteria).
local function build_stats(matches, teams)
local st = {}
for _, c in ipairs(teams) do st[c] = new_stats() end
for _, m in ipairs(matches) do
if st[m.home] and st[m.away] then
add_result(st[m.home], m.hs, m.as, m.neutral and 'neutral' or 'home')
add_result(st[m.away], m.as, m.hs, m.neutral and 'neutral' or 'away')
end
end
return st
end
-- Aggregate opponents' full-season records, counting repeat meetings twice.
local function opp_stats(code, matches, full, adj)
local agg = new_stats()
local aadj = 0
for _, m in ipairs(matches) do
local opp
if m.home == code then
opp = m.away
elseif m.away == code then
opp = m.home
end
if opp and full[opp] then
for _, f in ipairs(FIELDS) do
agg[f] = agg[f] + full[opp][f]
end
aadj = aadj + (adj[opp] or 0)
end
end
return agg, aadj
end
--------------------------------------------------------------------------------
-- Criteria
--------------------------------------------------------------------------------
-- Criterion identifiers; each also accepts a h2h_ or opp_ scope prefix, and
-- (independently) a h or a side prefix (e.g. hpts, agd, h2h_hpts, opp_agpm).
local METRICS = {
pts = true, gd = true, gf = true, ga = true,
w = true, d = true, l = true,
gr = true, gp = true, ap = true, pp = true, gpm = true,
wp = true, perc = true, ppg = true,
}
local SIDES = {h = true, a = true}
-- Split a criterion id into its scope (full/h2h/opp), side (''/h/a), and
-- underlying metric. A bare metric name is always preferred over reading its
-- first letter as a side prefix (so e.g. "ap" is the against-percentage
-- metric, not the away "p" metric, since "p" is not a metric on its own).
local function id_parts(id)
local scope, rest = 'full', id
if id:sub(1, 4) == 'h2h_' then
scope, rest = 'h2h', id:sub(5)
elseif id:sub(1, 4) == 'opp_' then
scope, rest = 'opp', id:sub(5)
end
if METRICS[rest] then
return scope, '', rest
end
local prefix, remainder = rest:sub(1, 1), rest:sub(2)
if SIDES[prefix] and METRICS[remainder] then
return scope, prefix, remainder
end
return scope, '', rest
end
local function valid_id(id)
local _, _, base = id_parts(id)
return METRICS[base] and true or false
end
-- Pick out the home, away, or overall view of a statistics record.
local function side_view(st, side)
if side == 'h' then
return {pld = st.hpld, w = st.hw, d = st.hd, l = st.hl, gf = st.hgf, ga = st.hga}
elseif side == 'a' then
return {pld = st.apld, w = st.aw, d = st.ad, l = st.al, gf = st.agf, ga = st.aga}
end
return st
end
-- Return a criterion value; undefined values sort last.
local function value_of(base, opts, st, adj)
adj = adj or 0
local pts = opts.winpoints * st.w + opts.drawpoints * st.d + opts.losspoints * st.l
+ opts.goalpoints * st.gf + adj
if base == 'pts' then return pts end
if base == 'gd' then return st.gf - st.ga end
if base == 'gf' then return st.gf end
if base == 'ga' then return st.ga end
if base == 'w' then return st.w end
if base == 'd' then return st.d end
if base == 'l' then return st.l end
-- A clean sheet is infinite; 0–0 is undefined.
if base == 'gr' or base == 'gp' then
if st.ga ~= 0 then
return (base == 'gr') and (st.gf / st.ga) or (100 * st.gf / st.ga)
end
return (st.gf > 0) and math.huge or nil
end
if base == 'ap' then
if st.gf ~= 0 then return 100 * st.ga / st.gf end
return (st.ga > 0) and math.huge or nil
end
if base == 'pp' then
return ((st.gf + st.ga) > 0) and (100 * st.gf / (st.gf + st.ga)) or nil
end
if base == 'gpm' then return (st.pld > 0) and (st.gf / st.pld) or nil end
if base == 'wp' then return (st.pld > 0) and (100 * st.w / st.pld) or nil end
if base == 'perc' then
return (st.pld > 0) and ((2 * st.w + st.d + adj) / (2 * st.pld)) or nil
end
if base == 'ppg' then return (st.pld > 0) and (pts / st.pld) or nil end
return nil
end
-- Parse ranking criteria, recursive groups, ascending order, and final-only rules.
local function parse_criteria(str, errors)
str = tostring(str or '')
str = str:gsub('−', '-'):gsub('−', '-')
local items = {}
local stack = {items}
local cur = items
local buf = ''
local final, seen_period = false, false
local function flush()
local tok = trim(buf)
buf = ''
if tok == '' then return end
local desc = true
if tok:sub(1, 1) == '-' then
desc = false
tok = tok:sub(2)
end
local id = (trim(tok):lower():gsub('%s+', '_'))
if id == '' then return end
if not valid_id(id) then
add_error(errors, 'unknown ranking criterion "' .. id ..
'" in |ranking_criteria=')
return
end
cur[#cur + 1] = {kind = 'crit', id = id, desc = desc, final_only = final}
end
for i = 1, #str do
local ch = str:sub(i, i)
if ch == '(' then
flush()
local g = {kind = 'group', items = {}, final_only = final}
cur[#cur + 1] = g
stack[#stack + 1] = g.items
cur = g.items
elseif ch == ')' then
flush()
if #stack > 1 then
table.remove(stack)
cur = stack[#stack]
else
add_error(errors, 'unmatched ")" in |ranking_criteria=')
end
elseif ch == ',' or ch == ';' then
flush()
elseif ch == '.' then
flush()
if #stack == 1 and not seen_period then
seen_period = true
final = true
end
else
buf = buf .. ch
end
end
flush()
if #stack > 1 then
add_error(errors, 'unmatched "(" in |ranking_criteria=')
end
-- Remove empty groups.
local function prune(list)
local out = {}
for _, it in ipairs(list) do
if it.kind == 'group' then
it.items = prune(it.items)
if #it.items > 0 then out[#out + 1] = it end
else
out[#out + 1] = it
end
end
return out
end
return prune(items)
end
--------------------------------------------------------------------------------
-- Ranking
--------------------------------------------------------------------------------
-- Evaluate one criterion for a tied group.
local function evaluate(crit, bucket, ctx)
local scope, side, base = id_parts(crit.id)
local vals = {}
if scope == 'h2h' then
-- Rebuild the mini-table for the tied teams.
local sub = build_stats(ctx.matches, bucket)
for _, c in ipairs(bucket) do
vals[c] = value_of(base, ctx.opts, side_view(sub[c], side), 0)
end
elseif scope == 'opp' then
for _, c in ipairs(bucket) do
local agg, aadj = opp_stats(c, ctx.matches, ctx.full, ctx.adj)
-- A home/away split of the opponents' aggregate has no adjustment to prorate.
vals[c] = value_of(base, ctx.opts, side_view(agg, side), (side == '') and aadj or 0)
end
else
for _, c in ipairs(bucket) do
-- Point deductions/additions are season-wide, not tied to venue, so
-- they only apply to the overall (non-home/away-split) criteria.
vals[c] = value_of(base, ctx.opts, side_view(ctx.full[c], side), (side == '') and ctx.adj[c] or 0)
end
end
return vals
end
--------------------------------------------------------------------------------
-- Head-to-head completeness (|h2h_after_complete=)
--------------------------------------------------------------------------------
-- One match slot: present with a readable score, or explicitly 'null'.
local function slot_ok(Args, key)
local raw = Args[key]
if raw == nil then return false end
local s = clean_cell(raw)
if s == '' then return false end
if s:lower() == 'null' then return true end
local hs = parse_score(raw)
return hs ~= nil
end
local function directional_key(h, a, n)
if n == 1 then return 'match_' .. h .. '_' .. a end
return 'match' .. n .. '_' .. h .. '_' .. a
end
local function neutral_key(x, y, n)
if n == 1 then return 'matchn_' .. x .. '_' .. y end
return 'matchn' .. n .. '_' .. x .. '_' .. y
end
-- Whether x and y have completed every leg against each other. Every leg is
-- expected in both directions (home and away); a leg explicitly marked
-- 'null' does not block completeness. Pairs with no directional fixtures at
-- all (an all-neutral competition) instead need one recorded neutral meeting
-- per leg, in either team's order.
local function h2h_pair_complete(Args, x, y, legs)
local has_directional = false
for n = 1, legs do
if Args[directional_key(x, y, n)] ~= nil or Args[directional_key(y, x, n)] ~= nil then
has_directional = true
break
end
end
if has_directional then
for n = 1, legs do
if not slot_ok(Args, directional_key(x, y, n)) or not slot_ok(Args, directional_key(y, x, n)) then
return false
end
end
return true
end
local nx, ny = x, y
if ny < nx then nx, ny = ny, nx end
for n = 1, legs do
if not (slot_ok(Args, neutral_key(nx, ny, n)) or slot_ok(Args, neutral_key(ny, nx, n))) then
return false
end
end
return true
end
-- Whether every pair within a tied bucket has completed its head-to-head fixtures.
local function bucket_h2h_complete(Args, bucket, legs)
for i = 1, #bucket do
for j = i + 1, #bucket do
if not h2h_pair_complete(Args, bucket[i], bucket[j], legs) then
return false
end
end
end
return true
end
-- Split tied teams by a criterion, retaining base order for equal values.
local function split_by(bucket, vals, desc, rank)
local sorted = {}
for _, c in ipairs(bucket) do sorted[#sorted + 1] = c end
table.sort(sorted, function(x, y)
local vx, vy = vals[x], vals[y]
if vx == nil and vy == nil then return rank[x] < rank[y] end
if vx == nil then return false end
if vy == nil then return true end
if vx == vy then return rank[x] < rank[y] end
if desc then return vx > vy end
return vx < vy
end)
local out, cur, prev = {}, nil, nil
for _, c in ipairs(sorted) do
local v = vals[c]
if cur == nil or v ~= prev then
cur = {c}
out[#out + 1] = cur
else
cur[#cur + 1] = c
end
prev = v
end
return out
end
local apply_items, apply_group
-- Reapply grouped criteria to smaller unresolved ties.
function apply_group(bucket, group, ctx, depth)
depth = (depth or 0) + 1
local res = apply_items({bucket}, group.items, ctx)
if depth >= MAX_RECURSION then return res end
local out = {}
for _, b in ipairs(res) do
if #b > 1 and #b < #bucket then
for _, nb in ipairs(apply_group(b, group, ctx, depth)) do
out[#out + 1] = nb
end
else
out[#out + 1] = b
end
end
return out
end
function apply_items(buckets, items, ctx)
for _, item in ipairs(items) do
if item.final_only and not ctx.complete then
-- Final-only criteria require |update=complete.
break
end
local nextb = {}
for _, b in ipairs(buckets) do
if #b <= 1 then
nextb[#nextb + 1] = b
else
local res
local scope = (item.kind == 'crit') and select(1, id_parts(item.id)) or nil
if scope == 'h2h' and ctx.h2h_gate and not bucket_h2h_complete(ctx.Args, b, ctx.legs) then
-- Not every tied team has played every other tied team yet;
-- skip this head-to-head criterion for this group.
res = {b}
elseif item.kind == 'group' then
res = apply_group(b, item, ctx)
else
res = split_by(b, evaluate(item, b, ctx), item.desc, ctx.rank)
end
for _, nb in ipairs(res) do nextb[#nextb + 1] = nb end
end
end
buckets = nextb
end
return buckets
end
--------------------------------------------------------------------------------
-- Options and write-back
--------------------------------------------------------------------------------
local function read_opts(Args)
local show_draw = yesno(Args['show_draw'] or 'yes')
return {
winpoints = tonumber(Args['winpoints']) or (show_draw and 3 or 2),
drawpoints = tonumber(Args['drawpoints']) or (show_draw and 1 or 0),
losspoints = tonumber(Args['losspoints']) or (show_draw and 0 or 1),
goalpoints = Args['goalpoints'] and (tonumber(Args['goalpoints']) or 1) or 0,
}
end
local function read_adjustments(Args, teams)
local adj = {}
for _, c in ipairs(teams) do
adj[c] = (tonumber(Args['adjust_points_' .. c]) or 0)
+ (tonumber(Args['startpoints_' .. c]) or 0)
end
return adj
end
-- Warn about manual parameters that automatic generation ignores or overwrites.
local function check_ignored(Args, teams, errors, write_ha)
local overwritten, n_over, first = {}, 0, nil
local rounds = {}
local fields = {'win', 'pf', 'pa', 'matches'}
if write_ha then
for _, f in ipairs({'hwin', 'hdraw', 'hloss', 'hgf', 'hga', 'awin', 'adraw', 'aloss', 'agf', 'aga'}) do
fields[#fields + 1] = f
end
else
for _, f in ipairs({'draw', 'loss', 'gf', 'ga'}) do
fields[#fields + 1] = f
end
end
for _, c in ipairs(teams) do
local hit = false
for _, f in ipairs(fields) do
if Args[f .. '_' .. c] ~= nil then
overwritten[f] = true
hit = true
end
end
if hit then
n_over = n_over + 1
first = first or c
end
if tonumber(Args['rw_' .. c]) then rounds[#rounds + 1] = c end
end
if n_over > 0 then
local names = {}
for f in pairs(overwritten) do names[#names + 1] = '|' .. f .. '_TTT=' end
table.sort(names)
add_error(errors, 'values given in ' .. table.concat(names, ', ') .. ' are ignored when '
.. '|auto_generate_standings= is set (found for ' .. first
.. (n_over > 1 and (' and ' .. (n_over - 1) .. ' other team(s)') or '')
.. '); they are calculated from the match results and can be removed')
end
if #rounds > 0 then
add_error(errors, '|rw_TTT= is added to the points column by the style but is not used when ranking, '
.. 'so the table may not be in points order (found for ' .. table.concat(rounds, ', ')
.. '); rounds won are not supported by |auto_generate_standings=')
end
end
-- write_ha writes HA-style split fields (hwin_TTT, awin_TTT, etc.) only.
-- WDLHA/WLHA do not use plain fields, which argcheck would flag as unknown.
local function write_stats(Args, teams, full, write_ha)
for _, c in ipairs(teams) do
local st = full[c]
Args['win_' .. c] = tostring(st.w)
if write_ha then
Args['hwin_' .. c] = tostring(st.hw)
Args['hdraw_' .. c] = tostring(st.hd)
Args['hloss_' .. c] = tostring(st.hl)
Args['hgf_' .. c] = tostring(st.hgf)
Args['hga_' .. c] = tostring(st.hga)
Args['awin_' .. c] = tostring(st.aw)
Args['adraw_' .. c] = tostring(st.ad)
Args['aloss_' .. c] = tostring(st.al)
Args['agf_' .. c] = tostring(st.agf)
Args['aga_' .. c] = tostring(st.aga)
else
Args['draw_' .. c] = tostring(st.d)
Args['loss_' .. c] = tostring(st.l)
Args['gf_' .. c] = tostring(st.gf)
Args['ga_' .. c] = tostring(st.ga)
end
end
end
--------------------------------------------------------------------------------
-- Table rows
--------------------------------------------------------------------------------
-- Read manual ordering; |team_order= overrides |teamN= at any position both give.
local function read_pins(Args, top_pos, errors)
local pins, order_full = {}, false
local from_teamN = {}
for k, v in pairs(Args) do
if type(k) == 'string' and v and trim(v) ~= '' then
local num = k:match('^team(%d+)$')
if num then
num = tonumber(num)
pins[num] = trim(v)
from_teamN[num] = pins[num]
end
end
end
local to = Args['team_order']
if to and trim(to) ~= '' then
order_full = true
local offset = (tonumber(Args['team_order_start']) or top_pos) - 1
local overridden = {}
for k, tname in ipairs(mw.text.split(to, '%s*[;,]%s*')) do
if tname ~= '' then
local pos = k + offset
if from_teamN[pos] and from_teamN[pos] ~= tname then
overridden[#overridden + 1] = pos
end
pins[pos] = tname
end
end
if errors and #overridden > 0 then
table.sort(overridden)
add_error(errors, '|team_order= and |team' .. table.concat(overridden, '=, |team') .. '= are both set; '
.. '|team_order= takes precedence, so the |teamN= value(s) at position(s) '
.. table.concat(overridden, ', ') .. ' have been ignored')
end
end
return pins, order_full
end
local function clear_team_args(Args)
local keys = {}
for k, _ in pairs(Args) do
if type(k) == 'string' and k:match('^team%d+$') then keys[#keys + 1] = k end
end
for _, k in ipairs(keys) do Args[k] = nil end
Args['team_order'] = nil
Args['team_order_start'] = nil
end
-- Place ranked teams, applying manual ordering unless disabled.
local function place(Args, buckets, top_pos, errors, ignore_manual)
local auto, auto_index, shared, group_of = {}, {}, {}, {}
local n, gid = 0, 0
for _, b in ipairs(buckets) do
gid = gid + 1
local pos = top_pos + n
for _, c in ipairs(b) do
n = n + 1
auto[n] = c
auto_index[c] = top_pos + n - 1
shared[c] = pos
if #b > 1 then group_of[c] = gid end
end
end
local pins, order_full = {}, false
if not ignore_manual then
pins, order_full = read_pins(Args, top_pos, errors)
end
-- A valid manual order must fill every vacated slot.
local pinned_at, ok, count = {}, true, 0
-- Keep messages in a stable order.
local pin_slots = {}
for pos in pairs(pins) do pin_slots[#pin_slots + 1] = pos end
table.sort(pin_slots)
for _, pos in ipairs(pin_slots) do
local tname = pins[pos]
count = count + 1
if not auto_index[tname] then
ok = false
add_error(errors, 'the team "' .. tname .. '" given in the manual order is not in the table')
elseif pinned_at[tname] then
ok = false
add_error(errors, 'the team "' .. tname .. '" is given more than once in the manual order')
else
pinned_at[tname] = pos
end
if pos < top_pos or pos > (top_pos + n - 1) then
ok = false
add_error(errors, 'position ' .. pos .. ' in the manual order is outside the table')
end
end
if ok then
for _, slot in ipairs(pin_slots) do
local tname = pins[slot]
local pos = pinned_at[tname]
if tname and pins[auto_index[tname]] == nil then
ok = false
add_error(errors, 'the manual order is incomplete: "' .. tname .. '" has been moved from position '
.. auto_index[tname] .. ' to position ' .. pos .. ', but nothing has been given for position '
.. auto_index[tname] .. '. Every team displaced by a |teamN= must itself be given a position, '
.. 'otherwise the order cannot be resolved. The order before the manual changes has been used instead')
end
end
end
local final, manual = {}, false
if count > 0 and ok then
manual = true
for pos, tname in pairs(pins) do final[pos] = tname end
local rest = {}
for i = 1, n do
if not pinned_at[auto[i]] then rest[#rest + 1] = auto[i] end
end
local j = 1
for i = top_pos, top_pos + n - 1 do
if not final[i] then
final[i] = rest[j]
j = j + 1
end
end
else
for i = 1, n do final[top_pos + i - 1] = auto[i] end
end
-- Manual ordering breaks tied-position groups it touches.
local broken = {}
if manual then
if order_full then
for g = 1, gid do broken[g] = true end
else
for tname, _ in pairs(pinned_at) do
if group_of[tname] then broken[group_of[tname]] = true end
end
end
end
-- Number every row if tied positions are disabled.
local show_tied = yesno(Args['show_tied_positions'] or 'yes')
clear_team_args(Args)
for i = top_pos, top_pos + n - 1 do
local c = final[i]
Args['team' .. i] = c
local shown = i
if show_tied and group_of[c] and not broken[group_of[c]] then shown = shared[c] end
-- Preserve editor-set positions.
if Args['pos_' .. c] == nil and shown ~= i then
Args['pos_' .. c] = tostring(shown)
end
end
-- Prevent stale |teamN= values from adding rows.
if not tonumber(Args['lowest_pos']) then
Args['lowest_pos'] = tostring(top_pos + n - 1)
end
return n
end
--------------------------------------------------------------------------------
-- Entry points
--------------------------------------------------------------------------------
-- Build standings and return any errors. write_ha additionally computes and
-- writes the home/away split fields a HA-style table needs to render.
function pa.generate(Args, top_pos, write_ha)
local errors = {}
top_pos = top_pos or 1
local teams, codes, appears = pa.roster(Args)
if #teams == 0 then
add_error(errors, 'no teams were found: |auto_generate_standings= needs |name_TTT= or |match_TTT_SSS= parameters')
return errors
end
check_orphans(Args, teams, appears, errors)
local matches = pa.matches(Args, codes, errors)
local opts = read_opts(Args)
local adj = read_adjustments(Args, teams)
local full = build_stats(matches, teams)
check_ignored(Args, teams, errors, write_ha)
write_stats(Args, teams, full, write_ha)
local crit_str = Args['ranking_criteria']
if not crit_str or trim(crit_str) == '' then crit_str = 'pts, gd, gf' end
local items = parse_criteria(crit_str, errors)
local start = {}
local rank = {}
for i, c in ipairs(pa.base_order(Args, teams, codes, errors)) do
start[#start + 1] = c
rank[c] = i
end
local legs = tonumber(Args['legs']) or 1
local h2h_gate = yesno(Args['h2h_after_complete'] or 'no') and true or false
if h2h_gate then
local fmt = string.lower(trim(Args['matches_format']))
if fmt == 'schedule' or fmt == 'series' or fmt == 'series_split' then
add_error(errors, '|h2h_after_complete= has no effect with |matches_format=' .. fmt
.. ', because that fixture list is not necessarily a full round-robin; the head-to-head '
.. 'criteria have been applied immediately instead')
h2h_gate = false
end
end
local ctx = {
matches = matches,
full = full,
adj = adj,
opts = opts,
rank = rank,
complete = (trim(Args['update']):lower() == 'complete'),
Args = Args,
legs = legs,
h2h_gate = h2h_gate,
}
local buckets = apply_items({start}, items, ctx)
place(Args, buckets, top_pos, errors)
return errors
end
-- Place teams in base order for |show_matches=only.
function pa.order_only(Args, top_pos, ignore_manual)
local errors = {}
top_pos = top_pos or 1
local teams, codes, appears = pa.roster(Args)
if #teams == 0 then
add_error(errors, 'no teams were found: |auto_generate_standings= needs |name_TTT= or |match_TTT_SSS= parameters')
return errors
end
check_orphans(Args, teams, appears, errors)
local buckets = {}
for _, c in ipairs(pa.base_order(Args, teams, codes, errors)) do
buckets[#buckets + 1] = {c}
end
place(Args, buckets, top_pos, errors, ignore_manual)
return errors
end
return pa