From 46c1a3510eff95d6887949951773a5f48ecd764f Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Fri, 21 Aug 2026 09:46:13 +0000 Subject: [PATCH 1/5] refactor: bulletproof step 1.M --- lua/spec/match_group_display_helper_spec.lua | 34 + lua/spec/match_group_input_util_spec.lua | 40 + lua/spec/match_group_util_bracket_spec.lua | 358 +++++++ lua/spec/match_group_util_match_spec.lua | 483 +++++++++ lua/spec/match_group_util_spec.lua | 210 ++++ lua/wikis/commons/Match.lua | 6 +- lua/wikis/commons/MatchGroup/Coordinates.lua | 3 + .../commons/MatchGroup/Display/Bracket.lua | 2 +- .../commons/MatchGroup/Display/Helper.lua | 26 + lua/wikis/commons/MatchGroup/Input.lua | 7 +- lua/wikis/commons/MatchGroup/Input/Util.lua | 34 +- lua/wikis/commons/MatchGroup/Util.lua | 964 +----------------- lua/wikis/commons/MatchGroup/Util/Bracket.lua | 315 ++++++ lua/wikis/commons/MatchGroup/Util/Match.lua | 257 +++++ .../commons/MatchGroup/Util/Starcraft.lua | 3 +- lua/wikis/commons/MatchGroup/Util/Types.lua | 372 +++++++ lua/wikis/dota2/MatchGroup/Input/Custom.lua | 3 +- .../MatchGroup/Input/Custom.lua | 3 +- .../valorant/MatchGroup/Input/Custom.lua | 3 +- 19 files changed, 2185 insertions(+), 938 deletions(-) create mode 100644 lua/spec/match_group_display_helper_spec.lua create mode 100644 lua/spec/match_group_input_util_spec.lua create mode 100644 lua/spec/match_group_util_bracket_spec.lua create mode 100644 lua/spec/match_group_util_match_spec.lua create mode 100644 lua/spec/match_group_util_spec.lua create mode 100644 lua/wikis/commons/MatchGroup/Util/Bracket.lua create mode 100644 lua/wikis/commons/MatchGroup/Util/Match.lua create mode 100644 lua/wikis/commons/MatchGroup/Util/Types.lua diff --git a/lua/spec/match_group_display_helper_spec.lua b/lua/spec/match_group_display_helper_spec.lua new file mode 100644 index 00000000000..f781954d62b --- /dev/null +++ b/lua/spec/match_group_display_helper_spec.lua @@ -0,0 +1,34 @@ +--- Triple Comment to Enable our LLS Plugin +--[[ +Tests for Module:MatchGroup/Display/Helper. + +`mergeBracketResetMatch` folds a grand finals and its bracket reset into one match for the bracket +display. It is presentation rather than bracket topology, which is why it lives here. +]] + +insulate('MatchGroup/Display/Helper', function() + describe('mergeBracketResetMatch', function() + it('folds the reset scores in as the second scores and appends its games', function() + local DisplayHelper = require('Module:MatchGroup/Display/Helper') + local match = { + matchId = 'R03-M001', + opponents = {{name = 'A', score = 2, status = 'S'}, {name = 'B', score = 3, status = 'S'}}, + games = {{map = 'M1'}}, + } + local merged = DisplayHelper.mergeBracketResetMatch(match, { + opponents = {{score = 1, status = 'S', placement = 2}, {score = 3, status = 'S', placement = 1}}, + games = {{map = 'M2'}}, + }) + + assert.are_equal('R03-M001', merged.matchId) + assert.are_equal(2, merged.opponents[1].score) + assert.are_equal(1, merged.opponents[1].score2) + assert.are_equal(2, merged.opponents[1].placement2) + assert.are_equal(3, merged.opponents[2].score2) + assert.are_same({{map = 'M1'}, {map = 'M2'}}, merged.games) + -- the original is left alone + assert.are_equal(1, #match.games) + assert.is_nil(match.opponents[1].score2) + end) + end) +end) diff --git a/lua/spec/match_group_input_util_spec.lua b/lua/spec/match_group_input_util_spec.lua new file mode 100644 index 00000000000..61f22080f29 --- /dev/null +++ b/lua/spec/match_group_input_util_spec.lua @@ -0,0 +1,40 @@ +--- Triple Comment to Enable our LLS Plugin +--[[ +Tests for Module:MatchGroup/Input/Util. + +`normalizeSubtype` is a tolerance shim for the input path, letting callers pass either a raw match +record or an already built match. +]] + +insulate('MatchGroup/Input/Util', function() + describe('normalizeSubtype', function() + it('prefers the record field over the model field', function() + local MatchGroupInputUtil = require('Module:MatchGroup/Input/Util') + assert.are_same({'record'}, MatchGroupInputUtil.normalizeSubtype( + {match2opponents = {'record'}, opponents = {'model'}}, 'opponent')) + assert.are_same({'model'}, MatchGroupInputUtil.normalizeSubtype({opponents = {'model'}}, 'opponent')) + assert.are_same({'record'}, MatchGroupInputUtil.normalizeSubtype({match2games = {'record'}}, 'map')) + end) + + it('returns an empty list when neither field is set', function() + local MatchGroupInputUtil = require('Module:MatchGroup/Input/Util') + assert.are_same({}, MatchGroupInputUtil.normalizeSubtype({}, 'opponent')) + end) + + it('rejects an unknown subtype', function() + local MatchGroupInputUtil = require('Module:MatchGroup/Input/Util') + ---@diagnostic disable-next-line: param-type-mismatch + assert.has_error(function() MatchGroupInputUtil.normalizeSubtype({}, 'player') end, 'Invalid subtype: player') + end) + end) + + describe('getStandaloneId', function() + it('builds a standalone id, and nothing without both parts', function() + local MatchGroupInputUtil = require('Module:MatchGroup/Input/Util') + assert.are_equal('MATCH_abcdefghij_R01-M001', + MatchGroupInputUtil.getStandaloneId('abcdefghij', 'R01-M001')) + assert.is_nil(MatchGroupInputUtil.getStandaloneId('abcdefghij', nil)) + assert.is_nil(MatchGroupInputUtil.getStandaloneId(nil, 'R01-M001')) + end) + end) +end) diff --git a/lua/spec/match_group_util_bracket_spec.lua b/lua/spec/match_group_util_bracket_spec.lua new file mode 100644 index 00000000000..dfafb372a88 --- /dev/null +++ b/lua/spec/match_group_util_bracket_spec.lua @@ -0,0 +1,358 @@ +--- Triple Comment to Enable our LLS Plugin + +--- The fixtures below name only the fields each test is about, rather than whole records. +---@diagnostic disable: missing-fields + +--[[ +Tests for Module:MatchGroup/Util/Bracket, the bracket model. + +Bracket data records, topology, coordinates and the id namespace that addresses a match inside its +match group. A matchlist counts as a bracket here, which is why the flat match id forms live with +the bracket ones. +]] + +insulate('MatchGroup/Util/Bracket', function() + describe('bracketDataFromRecord', function() + it('returns an empty table when there is no data', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({}, BracketUtil.bracketDataFromRecord(nil)) + end) + + it('reads matchlist bracket data', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketData = BracketUtil.bracketDataFromRecord{ + type = 'matchlist', + header = 'Round 1', + inheritedheader = 'Group A', + title = 'A Title', + dateheader = 'true', + matchpage = 'Some/Page', + } + + assert.are_same({ + type = 'matchlist', + header = 'Round 1', + inheritedHeader = 'Group A', + title = 'A Title', + dateHeader = 'true', + matchPage = 'Some/Page', + }, bracketData) + end) + + it('treats anything that is not a bracket as a matchlist', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_equal('matchlist', BracketUtil.bracketDataFromRecord{header = 'x'}.type) + end) + + it('reads bracket bracket data', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketData = BracketUtil.bracketDataFromRecord{ + type = 'bracket', + header = 'Semifinals', + bracketreset = 'R03-M001', + thirdplace = 'RxMTP', + upperMatchId = 'R02-M001', + qualwinLiteral = 'Qualified', + qualloseLiteral = 'Eliminated', + lowerMatchIds = {'R01-M001', 'R01-M002'}, + } + + assert.are_equal('bracket', bracketData.type) + assert.are_equal('Semifinals', bracketData.header) + assert.are_equal('R03-M001', bracketData.bracketResetMatchId) + assert.are_equal('RxMTP', bracketData.thirdPlaceMatchId) + assert.are_equal('R02-M001', bracketData.upperMatchId) + assert.are_equal('Qualified', bracketData.qualWinLiteral) + assert.are_equal('Eliminated', bracketData.qualLoseLiteral) + assert.are_same({'R01-M001', 'R01-M002'}, bracketData.lowerMatchIds) + assert.are_equal(0, bracketData.qualSkip) + assert.are_equal(0, bracketData.skipRound) + end) + + it('derives the qualification flags from the advance spots', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketData = BracketUtil.bracketDataFromRecord{ + type = 'bracket', + qualwin = 'true', + quallose = 'true', + } + + assert.is_true(bracketData.qualWin) + assert.is_true(bracketData.qualLose) + assert.are_equal('qualify', bracketData.advanceSpots[1].type) + assert.are_equal('qualify', bracketData.advanceSpots[2].type) + end) + + it('reads qualskip and skipround as numbers, including the true spelling', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local fromStrings = BracketUtil.bracketDataFromRecord{ + type = 'bracket', qualskip = 'true', skipround = 'true', + } + assert.are_equal(1, fromStrings.qualSkip) + assert.are_equal(1, fromStrings.skipRound) + + local fromNumbers = BracketUtil.bracketDataFromRecord{ + type = 'bracket', qualskip = '2', skipround = '3', + } + assert.are_equal(2, fromNumbers.qualSkip) + assert.are_equal(3, fromNumbers.skipRound) + end) + + it('converts the coordinate indexes from zero based to one based', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketData = BracketUtil.bracketDataFromRecord{ + type = 'bracket', + coordinates = {roundIndex = 0, sectionIndex = 1, roundCount = 3, depth = 2}, + } + + assert.are_equal(1, bracketData.coordinates.roundIndex) + assert.are_equal(2, bracketData.coordinates.sectionIndex) + -- only keys ending in Index shift, counts and depths are left alone + assert.are_equal(3, bracketData.coordinates.roundCount) + assert.are_equal(2, bracketData.coordinates.depth) + end) + end) + + describe('bracketDataToRecord', function() + it('round trips a bracket bracket data', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local record = BracketUtil.bracketDataToRecord{ + type = 'bracket', + header = 'Semifinals', + bracketResetMatchId = 'R03-M001', + thirdPlaceMatchId = 'RxMTP', + upperMatchId = 'R02-M001', + lowerMatchIds = {'R01-M001', 'R01-M002'}, + qualWin = true, + qualLose = false, + qualSkip = 0, + skipRound = 2, + coordinates = {sectionIndex = 1, sectionCount = 2, roundIndex = 2}, + } + + assert.are_equal('bracket', record.type) + assert.are_equal('R03-M001', record.bracketreset) + assert.are_equal('RxMTP', record.thirdplace) + assert.are_equal('R01-M002', record.tolower) + assert.are_equal('R01-M001', record.toupper) + assert.are_equal('true', record.quallose == nil and 'true' or 'set') + assert.are_equal('true', record.qualwin) + assert.is_nil(record.qualskip) + assert.are_equal(2, record.skipround) + assert.are_equal('upper', record.bracketsection) + -- the indexes go back to zero based + assert.are_equal(0, record.coordinates.sectionIndex) + assert.are_equal(1, record.coordinates.roundIndex) + end) + end) + + describe('computeLowerMatchIdsFromLegacy', function() + it('reads toupper before tolower and skips empty ones', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({'R02-M001', 'R02-M002'}, + BracketUtil.computeLowerMatchIdsFromLegacy{toupper = 'R02-M001', tolower = 'R02-M002'}) + assert.are_same({'R02-M002'}, + BracketUtil.computeLowerMatchIdsFromLegacy{toupper = '', tolower = 'R02-M002'}) + assert.are_same({}, BracketUtil.computeLowerMatchIdsFromLegacy{}) + end) + end) + + describe('autoAssignLowerEdges', function() + it('centres the lower matches when there are fewer of them than opponents', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({{lowerMatchIndex = 1, opponentIndex = 2}}, BracketUtil.autoAssignLowerEdges(1, 3)) + assert.are_same({ + {lowerMatchIndex = 1, opponentIndex = 1}, + {lowerMatchIndex = 2, opponentIndex = 2}, + }, BracketUtil.autoAssignLowerEdges(2, 2)) + end) + + it('hangs the excess lower matches off the last opponent', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({ + {lowerMatchIndex = 1, opponentIndex = 1}, + {lowerMatchIndex = 2, opponentIndex = 2}, + {lowerMatchIndex = 3, opponentIndex = 2}, + }, BracketUtil.autoAssignLowerEdges(3, 2)) + end) + + it('returns nothing when there are no lower matches', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({}, BracketUtil.autoAssignLowerEdges(0, 2)) + end) + end) + + describe('computeAdvanceSpots', function() + it('advances the winner to the upper match', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same( + {{bg = 'up', type = 'advance', matchId = 'R02-M001'}}, + BracketUtil.computeAdvanceSpots{upperMatchId = 'R02-M001'} + ) + end) + + it('lets winnerto and loserto override the upper match', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local spots = BracketUtil.computeAdvanceSpots{ + upperMatchId = 'R02-M001', + winnerto = 'R02-M009', + loserto = 'R02-M010', + } + assert.are_same({bg = 'up', type = 'custom', matchId = 'R02-M009'}, spots[1]) + assert.are_same({bg = 'stayup', type = 'custom', matchId = 'R02-M010'}, spots[2]) + end) + + it('marks qualification spots while keeping the target match', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local spots = BracketUtil.computeAdvanceSpots{ + upperMatchId = 'R02-M001', + qualwin = 'true', + quallose = 'true', + } + assert.are_same({bg = 'up', type = 'qualify', matchId = 'R02-M001'}, spots[1]) + assert.are_same({bg = 'stayup', type = 'qualify'}, spots[2]) + end) + + it('returns nothing for a match that goes nowhere', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({}, BracketUtil.computeAdvanceSpots{}) + end) + end) + + describe('populateAdvanceSpots', function() + it('does nothing for an empty bracket', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.is_true(pcall(function() + BracketUtil.populateAdvanceSpots{matches = {}} + end)) + end) + + it('sends the semifinal losers to the third place match', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketDatasById = { + ['R01-M001'] = {advanceSpots = {}}, + ['R02-M001'] = {advanceSpots = {}, lowerMatchIds = {'R01-M001'}, thirdPlaceMatchId = 'RxMTP'}, + RxMTP = {advanceSpots = {}}, + } + BracketUtil.populateAdvanceSpots{ + matches = {{extradata = {}, bracketData = bracketDatasById['R02-M001']}}, + matchesById = {RxMTP = {}}, + rootMatchIds = {'R02-M001'}, + bracketDatasById = bracketDatasById, + } + + assert.are_same({bg = 'stayup', type = 'advance', matchId = 'RxMTP'}, + bracketDatasById['R01-M001'].advanceSpots[2]) + end) + + it('applies the pbg overrides from extradata', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketData = {advanceSpots = {}, lowerMatchIds = {}} + BracketUtil.populateAdvanceSpots{ + matches = {{extradata = {pbg1 = 'down', pbg2 = 'stay'}, bracketData = bracketData}}, + matchesById = {}, + rootMatchIds = {'R01-M001'}, + bracketDatasById = {['R01-M001'] = bracketData}, + } + + assert.are_same({bg = 'down', type = 'custom'}, bracketData.advanceSpots[1]) + assert.are_same({bg = 'stay', type = 'custom'}, bracketData.advanceSpots[2]) + end) + end) + + describe('computeRootMatchIds', function() + it('takes the matches that have no upper match, in coordinate order', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local rootMatchIds = BracketUtil.computeRootMatchIds{ + ['R01-M001'] = {upperMatchId = 'R02-M001'}, + ['R02-M001'] = {coordinates = {rootIndex = 2}}, + ['R02-M002'] = {coordinates = {rootIndex = 1}}, + } + assert.are_same({'R02-M002', 'R02-M001'}, rootMatchIds) + end) + + it('never treats a bracket reset match as a root', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_same({'R01-M001'}, BracketUtil.computeRootMatchIds{ + ['R01-M001'] = {coordinates = {rootIndex = 1}}, + ['abcdefghij_RxMBR'] = {coordinates = {rootIndex = 2}}, + }) + end) + end) + + describe('backfillUpperMatchIds', function() + it('points every lower match at the match above it', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketDatasById = { + ['R01-M001'] = {lowerMatchIds = {}}, + ['R01-M002'] = {lowerMatchIds = {}}, + ['R02-M001'] = {lowerMatchIds = {'R01-M001', 'R01-M002'}}, + } + BracketUtil.backfillUpperMatchIds(bracketDatasById) + + assert.are_equal('R02-M001', bracketDatasById['R01-M001'].upperMatchId) + assert.are_equal('R02-M001', bracketDatasById['R01-M002'].upperMatchId) + assert.is_nil(bracketDatasById['R02-M001'].upperMatchId) + end) + end) + + describe('index table conversion', function() + it('shifts only the keys ending in Index', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local coordinates = {roundIndex = 0, sectionIndex = 2, roundCount = 4, matchId = 'R01-M001'} + local oneBased = BracketUtil.indexTableFromRecord(coordinates) + + assert.are_same({roundIndex = 1, sectionIndex = 3, roundCount = 4, matchId = 'R01-M001'}, oneBased) + assert.are_same(coordinates, BracketUtil.indexTableToRecord(oneBased)) + end) + end) + + describe('sectionIndexToString', function() + it('names the first, last and middle sections', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_equal('upper', BracketUtil.sectionIndexToString(1, 3)) + assert.are_equal('mid', BracketUtil.sectionIndexToString(2, 3)) + assert.are_equal('lower', BracketUtil.sectionIndexToString(3, 3)) + -- a single section is upper, the first check wins + assert.are_equal('upper', BracketUtil.sectionIndexToString(1, 1)) + end) + end) + + -- match ids address a match inside its match group, so they live with the bracket half + describe('match ids', function() + it('splits a match id into bracket id and base match id', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local bracketId, baseMatchId = BracketUtil.splitMatchId('h5HXaqbSVP_R02-M002') + assert.are_equal('h5HXaqbSVP', bracketId) + assert.are_equal('R02-M002', baseMatchId) + end) + + it('returns nothing for a match id it cannot split', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.is_nil(BracketUtil.splitMatchId('R02-M002')) + assert.is_nil((BracketUtil.splitMatchId(''))) + end) + + it('converts between the padded and short match id forms', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_equal('R1M3', BracketUtil.matchIdToKey('R01-M003')) + assert.are_equal('R12M345', BracketUtil.matchIdToKey('R12-M345')) + assert.are_equal('R01-M003', BracketUtil.matchIdFromKey('R1M3')) + assert.are_equal('R12-M345', BracketUtil.matchIdFromKey('R12M345')) + end) + + it('leaves the reset and third place placeholders alone', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_equal('RxMBR', BracketUtil.matchIdToKey('RxMBR')) + assert.are_equal('RxMTP', BracketUtil.matchIdToKey('RxMTP')) + assert.are_equal('RxMBR', BracketUtil.matchIdFromKey('RxMBR')) + assert.are_equal('RxMTP', BracketUtil.matchIdFromKey('RxMTP')) + end) + + it('pads a matchlist key to four digits', function() + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + assert.are_equal('0003', BracketUtil.matchIdFromKey('3')) + end) + + end) +end) diff --git a/lua/spec/match_group_util_match_spec.lua b/lua/spec/match_group_util_match_spec.lua new file mode 100644 index 00000000000..6f7c45874ad --- /dev/null +++ b/lua/spec/match_group_util_match_spec.lua @@ -0,0 +1,483 @@ +--- Triple Comment to Enable our LLS Plugin + +--- The fixtures below name only the fields each test is about, rather than whole records. +---@diagnostic disable: missing-fields + +--[[ +Tests for Module:MatchGroup/Util/Match, the match model: reading match records into matches, +opponents, games and players. +]] + +--- Casts a partial record to a match2 record, so a test only has to name the fields it cares about. +---@param record table +---@return match2 +local function matchRecord(record) + return record +end + +--- Casts a partial record to a match2game record. +---@param record table +---@return match2game +local function gameRecord(record) + return record +end + +insulate('MatchGroup/Util/Match', function() + describe('matchFromRecord', function() + it('reads a full bracket match record', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + match2id = 'abcdefghij_R01-M001', + bestof = '3', + date = '2022-01-05 12:00:00', + dateexact = '1', + finished = '1', + winner = '1', + mode = 'team', + pagename = 'Some/Page', + tournament = 'Some Tournament', + extradata = {comment = 'a comment', timestamp = 1641384000, timezoneid = 'CET'}, + match2bracketdata = {type = 'bracket', header = 'Semifinals'}, + match2opponents = { + {name = 'Team A', score = '2', status = 'S', type = 'team', match2players = {}}, + {name = 'Team B', score = '1', status = 'S', type = 'team', match2players = {}}, + }, + match2games = {{map = 'Map1', winner = '1'}}, + }) + + assert.are_equal('abcdefghij_R01-M001', match.matchId) + assert.are_equal(3, match.bestof) + assert.are_equal('2022-01-05 12:00:00', match.date) + assert.is_true(match.dateIsExact) + assert.is_true(match.finished) + assert.are_equal(1, match.winner) + assert.are_equal('Some/Page', match.pageName) + assert.are_equal('Some Tournament', match.tournament) + assert.are_equal(2, #match.opponents) + assert.are_equal(1, #match.games) + assert.are_equal('finished', match.phase) + assert.are_equal('bracket', match.bracketData.type) + end) + + it('lifts comment, timestamp and timezone out of extradata', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + extradata = {comment = 'a comment', timestamp = 1641384000, timezoneid = 'CET', keepme = 'yes'}, + match2opponents = {}, + match2games = {}, + }) + + assert.are_equal('a comment', match.comment) + assert.are_equal(1641384000, match.timestamp) + assert.are_equal('CET', match.timezoneId) + -- the lifted keys are removed from extradata, anything else stays + assert.are_same({keepme = 'yes'}, match.extradata) + end) + + it('fills in the defaults of a minimal record', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + match2opponents = {}, + match2games = {}, + }) + + assert.are_equal(0, match.bestof) + assert.is_false(match.dateIsExact) + assert.is_false(match.finished) + assert.are_same({}, match.links) + assert.are_same({}, match.stream) + assert.are_same({}, match.extradata) + assert.are_same({}, match.bracketData) + -- an unset type falls back to 'literal', not to nil + assert.are_equal('literal', match.type) + assert.is_nil(match.winner) + end) + + it('lower cases the walkover and blanks out empty strings', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + walkover = 'FF', + icon = '', + vod = '', + resulttype = '', + match2opponents = {}, + match2games = {}, + }) + + assert.are_equal('ff', match.walkover) + assert.is_nil(match.icon) + assert.is_nil(match.vod) + assert.is_nil(match.resultType) + end) + + it('parses json encoded links, stream and bracket data', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + links = '{"twitch":"https://twitch.tv/x"}', + stream = '{"twitch":"x"}', + match2bracketdata = '{"type":"matchlist","header":"Round 1"}', + match2opponents = {}, + match2games = {}, + }) + + assert.are_same({twitch = 'https://twitch.tv/x'}, match.links) + assert.are_same({twitch = 'x'}, match.stream) + assert.are_equal('Round 1', match.bracketData.header) + end) + + it('auto assigns lower edges for bracket matches that have none', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + match2bracketdata = {type = 'bracket', toupper = 'R02-M001', tolower = 'R02-M002'}, + match2opponents = { + {name = 'A', match2players = {}}, + {name = 'B', match2players = {}}, + }, + match2games = {}, + }) + + assert.are_same({'R02-M001', 'R02-M002'}, match.bracketData.lowerMatchIds) + assert.are_same({ + {lowerMatchIndex = 1, opponentIndex = 1}, + {lowerMatchIndex = 2, opponentIndex = 2}, + }, match.bracketData.lowerEdges) + end) + end) + + describe('opponentFromRecord', function() + ---@param opponent table + ---@param matchOverrides table? + ---@return standardOpponent + local function readOpponent(opponent, matchOverrides) + local MatchUtil = require('Module:MatchGroup/Util/Match') + local record = matchRecord(require('Module:Table').merge( + {match2opponents = {opponent}, match2games = {}}, + matchOverrides or {} + )) + return MatchUtil.opponentFromRecord(record, record.match2opponents[1], 1) + end + + it('reads the scores, placement and template', function() + local opponent = readOpponent{ + name = 'Team A', + score = '2', + status = 'S', + placement = '1', + template = 'team a', + type = 'team', + icon = 'Icon.png', + match2players = {}, + } + + assert.are_equal('Team A', opponent.name) + assert.are_equal(2, opponent.score) + assert.are_equal('S', opponent.status) + assert.are_equal(1, opponent.placement) + assert.are_equal('team a', opponent.template) + assert.are_equal('team', opponent.type) + assert.are_equal('Icon.png', opponent.icon) + assert.is_nil(opponent.scoreDisplay) + end) + + it('lifts the advance background and flag out of extradata', function() + local opponent = readOpponent{ + name = 'Team A', + extradata = {bg = 'up', advances = 'true', keepme = 'yes'}, + match2players = {}, + } + + assert.are_equal('up', opponent.advanceBg) + assert.is_true(opponent.advances) + assert.are_same({keepme = 'yes'}, opponent.extradata) + end) + + it('falls back to a literal opponent with no score', function() + local opponent = readOpponent{match2players = {}} + assert.are_equal('literal', opponent.type) + assert.is_nil(opponent.name) + assert.is_nil(opponent.score) + assert.are_same({}, opponent.players) + end) + end) + + describe('createOpponent', function() + it('fills in the defaults of a bare opponent', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_same({ + extradata = {}, + players = {}, + type = 'literal', + }, MatchUtil.createOpponent{}) + end) + + it('passes the given fields through', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local opponent = MatchUtil.createOpponent{name = 'A', score = 2, type = 'team', template = 'a'} + assert.are_equal('A', opponent.name) + assert.are_equal(2, opponent.score) + assert.are_equal('team', opponent.type) + assert.are_equal('a', opponent.template) + end) + end) + + describe('playerFromRecord', function() + it('reads a player and pulls the team out of extradata', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local player = MatchUtil.playerFromRecord{ + name = 'Player_A', + displayname = 'pA', + flag = 'se', + extradata = {playerteam = 'Team A', keepme = 'yes'}, + } + + assert.are_equal('Player_A', player.pageName) + assert.are_equal('pA', player.displayName) + assert.are_equal('se', player.flag) + assert.are_equal('Team A', player.team) + assert.are_same({keepme = 'yes'}, player.extradata) + end) + + it('blanks out an empty flag', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.is_nil(MatchUtil.playerFromRecord{name = 'A', flag = ''}.flag) + end) + end) + + describe('gameFromRecord', function() + it('reads a game and lifts its display fields out of extradata', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local game = MatchUtil.gameFromRecord(gameRecord{ + map = 'Map1', + winner = '1', + subgroup = '2', + scores = '[1,0]', + length = '30:00', + mode = '1v1', + walkover = 'FF', + extradata = { + header = 'Game 1', + displayname = 'Pretty Map', + comment = 'gg', + dateexact = '1', + timestamp = 1641384000, + timezoneid = 'CET', + keepme = 'yes', + }, + }, 2) + + assert.are_equal('Map1', game.map) + assert.are_equal('Pretty Map', game.mapDisplayName) + assert.are_equal('Game 1', game.header) + assert.are_equal('gg', game.comment) + assert.are_equal(1, game.winner) + assert.are_equal(2, game.subgroup) + assert.are_same({1, 0}, game.scores) + assert.are_equal('30:00', game.length) + assert.are_equal('ff', game.walkover) + assert.are_equal(1641384000, game.timestamp) + assert.are_equal('CET', game.timezoneId) + assert.are_same({keepme = 'yes'}, game.extradata) + end) + + it('defaults the scores to an empty list', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_same({}, MatchUtil.gameFromRecord(gameRecord{map = 'Map1'}, 2).scores) + end) + end) + + describe('record extradata', function() + it('parses json encoded extradata', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + extradata = '{"a":"b"}', + match2opponents = {}, + match2games = {}, + }) + assert.are_same({a = 'b'}, match.extradata) + end) + + it('does not mutate the extradata of the record it was given', function() + -- the model lifts comment/timestamp/timezoneid out of extradata, so it has to work on a copy + local MatchUtil = require('Module:MatchGroup/Util/Match') + local extradata = {comment = 'a comment', timestamp = 1641384000, keepme = 'yes'} + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', + mode = 'team', + extradata = extradata, + match2opponents = {}, + match2games = {}, + }) + + assert.are_equal('a comment', match.comment) + assert.are_same({keepme = 'yes'}, match.extradata) + assert.are_same({comment = 'a comment', timestamp = 1641384000, keepme = 'yes'}, extradata) + end) + + it('defaults to an empty table when the record has none', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local match = MatchUtil.matchFromRecord(matchRecord{ + date = '2022-01-05', mode = 'team', match2opponents = {}, match2games = {}, + }) + assert.are_same({}, match.extradata) + end) + end) + + describe('groupBySubgroup', function() + it('groups consecutive games with the same subgroup and takes their headers', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local submatches = MatchUtil.groupBySubgroup{ + extradata = {subgroup1header = 'First', subgroup2header = 'Second'}, + games = { + {map = 'M1', subgroup = 1}, + {map = 'M2', subgroup = 1}, + {map = 'M3', subgroup = 2}, + }, + } + + assert.are_equal(2, #submatches) + assert.are_equal(2, #submatches[1].games) + assert.are_equal('First', submatches[1].header) + assert.are_equal(1, submatches[1].subgroup) + assert.are_equal(1, #submatches[2].games) + assert.are_equal('Second', submatches[2].header) + end) + + it('numbers the submatches by position, not by the subgroup value', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local submatches = MatchUtil.groupBySubgroup{ + extradata = {}, + games = {{map = 'M1', subgroup = 5}, {map = 'M2', subgroup = 9}}, + } + + assert.are_equal(1, submatches[1].subgroup) + assert.are_equal(2, submatches[2].subgroup) + end) + + it('starts a new submatch every time the subgroup changes back', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + local submatches = MatchUtil.groupBySubgroup{ + extradata = {}, + games = {{subgroup = 1}, {subgroup = 2}, {subgroup = 1}}, + } + assert.are_equal(3, #submatches) + end) + + it('returns nothing for a match without games', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_same({}, MatchUtil.groupBySubgroup{extradata = {}, games = {}}) + end) + end) + + describe('computeMatchPhase', function() + it('is finished once there is a winner or the finished flag is set', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('finished', MatchUtil.computeMatchPhase{winner = 1, date = '2099-01-01'}) + assert.are_equal('finished', MatchUtil.computeMatchPhase{finished = true, date = '2099-01-01'}) + end) + + it('is ongoing once an exact start time has passed', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('ongoing', + MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateIsExact = true}) + end) + + it('is upcoming before the start time', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('upcoming', + MatchUtil.computeMatchPhase{date = '2099-01-01 12:00:00', dateIsExact = true}) + end) + + it('is upcoming when the date is explicitly not exact', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('upcoming', + MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateexact = '0'}) + end) + + it('ignores a dateIsExact of false, because it is read with an or', function() + -- `match.dateIsExact or match.dateexact` turns a false into a nil, which is not the same as + -- an explicit false, so a past match with dateIsExact = false still counts as ongoing + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('ongoing', + MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateIsExact = false}) + end) + + it('is upcoming when there is no usable date', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('upcoming', MatchUtil.computeMatchPhase{}) + end) + + it('accepts a raw record as well as a model, via dateexact and timestamp', function() + local MatchUtil = require('Module:MatchGroup/Util/Match') + assert.are_equal('ongoing', + MatchUtil.computeMatchPhase{dateexact = '1', timestamp = 1577880000}) + end) + end) +end) + +--[[ +Reading map scores onto the opponent instead of the match score is per wiki configuration, +so it needs a wiki that turns it on. +]] +insulate('MatchGroup/Util/Match on a wiki with game scores in a best of one', function() + setup(function() SetActiveWiki('counterstrike') end) + teardown(function() SetActiveWiki() end) + + ---@param record table + ---@return standardOpponent + local function readFirstOpponent(record) + local MatchUtil = require('Module:MatchGroup/Util/Match') + return MatchUtil.opponentFromRecord(record, record.match2opponents[1], 1) + end + + it('shows the map score of a best of one', function() + local opponent = readFirstOpponent{ + bestof = '1', + match2opponents = { + {name = 'A', score = '1', status = 'S', match2players = {}}, + {name = 'B', score = '0', status = 'S', match2players = {}}, + }, + match2games = {{opponents = {{score = '16', status = 'S'}, {score = '14', status = 'S'}}}}, + } + + -- the match score stays, the map score is offered alongside it for display + assert.are_equal(1, opponent.score) + assert.are_equal(16, opponent.scoreDisplay) + assert.are_equal('S', opponent.status) + end) + + it('leaves longer series alone', function() + local opponent = readFirstOpponent{ + bestof = '3', + match2opponents = { + {name = 'A', score = '2', status = 'S', match2players = {}}, + {name = 'B', score = '1', status = 'S', match2players = {}}, + }, + match2games = {{opponents = {{score = '16'}, {score = '14'}}}}, + } + assert.is_nil(opponent.scoreDisplay) + end) + + it('leaves a best of one alone when an opponent did not simply score', function() + local opponent = readFirstOpponent{ + bestof = '1', + match2opponents = { + {name = 'A', score = '1', status = 'S', match2players = {}}, + {name = 'B', score = '-1', status = 'FF', match2players = {}}, + }, + match2games = {{opponents = {{score = '16'}, {score = '0'}}}}, + } + assert.is_nil(opponent.scoreDisplay) + end) +end) diff --git a/lua/spec/match_group_util_spec.lua b/lua/spec/match_group_util_spec.lua new file mode 100644 index 00000000000..4ad20b4b918 --- /dev/null +++ b/lua/spec/match_group_util_spec.lua @@ -0,0 +1,210 @@ +--- Triple Comment to Enable our LLS Plugin + +--- The fixtures below name only the fields each test is about, rather than whole records. +---@diagnostic disable: missing-fields + +--[[ +Tests for Module:MatchGroup/Util: fetching match records and assembling them into match groups. + +It also re-exports the match and bracket models, so there is a test that every re-export points at +its implementation. +]] + +--- Casts a partial record to a match2 record, so a test only has to name the fields it cares about. +---@param record table +---@return match2 +local function matchRecord(record) + return record +end + +insulate('MatchGroup/Util', function() + describe('makeMatchGroup', function() + ---@param bracketData table + ---@param matchId string + ---@return table + local function record(matchId, bracketData) + return matchRecord{ + match2id = matchId, + date = '2022-01-05', + mode = 'team', + match2bracketdata = bracketData, + match2opponents = {{name = 'A', match2players = {}}, {name = 'B', match2players = {}}}, + match2games = {}, + } + end + + it('defaults to a matchlist when there are no records', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local matchGroup = MatchGroupUtil.makeMatchGroup{} + assert.are_equal('matchlist', matchGroup.type) + assert.are_same({}, matchGroup.matches) + end) + + it('builds a matchlist indexed by match id', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local matchGroup = MatchGroupUtil.makeMatchGroup{ + record('abc_0001', {type = 'matchlist', header = 'Round 1'}), + record('abc_0002', {type = 'matchlist'}), + } + + assert.are_equal('matchlist', matchGroup.type) + assert.are_equal(2, #matchGroup.matches) + assert.are_equal('abc_0001', matchGroup.matchesById['abc_0001'].matchId) + assert.are_equal('Round 1', matchGroup.bracketDatasById['abc_0001'].header) + end) + + it('builds a bracket with root matches and coordinates', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local matchGroup = MatchGroupUtil.makeMatchGroup{ + record('abc_R01-M001', {type = 'bracket'}), + record('abc_R01-M002', {type = 'bracket'}), + record('abc_R02-M001', {type = 'bracket', header = 'Final', + lowerMatchIds = {'abc_R01-M001', 'abc_R01-M002'}}), + } + + assert.are_equal('bracket', matchGroup.type) + assert.are_same({'abc_R02-M001'}, matchGroup.rootMatchIds) + -- the records carried no coordinates, so they were backfilled + assert.is_truthy(matchGroup.coordinatesByMatchId['abc_R02-M001']) + assert.are_equal('abc_R02-M001', matchGroup.bracketDatasById['abc_R01-M001'].upperMatchId) + end) + + it('errors on a bracket whose root match has no header', function() + -- computing the sections opens the first section on the first match that carries a header, so a + -- headerless root leaves nothing to put it in + local MatchGroupUtil = require('Module:MatchGroup/Util') + assert.has_error(function() + MatchGroupUtil.makeMatchGroup{record('abc_R01-M001', {type = 'bracket'})} + end) + end) + + it('keeps the coordinates that the records already carry', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local matchGroup = MatchGroupUtil.makeMatchGroup{ + record('abc_R01-M001', { + type = 'bracket', + header = 'Final', + coordinates = {roundIndex = 0, sectionIndex = 0, roundCount = 1, sectionCount = 1, + depth = 0, depthCount = 1, matchIndexInRound = 0, rootIndex = 0, + semanticDepth = 1, semanticRoundIndex = 0}, + }), + } + + assert.are_equal(1, matchGroup.coordinatesByMatchId['abc_R01-M001'].roundIndex) + assert.is_truthy(matchGroup.rounds) + assert.is_truthy(matchGroup.sections) + end) + + it('rejects an unknown match group type', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + assert.has_error( + function() MatchGroupUtil.makeMatchGroup{record('abc_0001', {type = 'ladder'})} end, + 'Invalid match2bracketdata.type: ladder. Expected matchlist or bracket.' + ) + end) + end) + + describe('fetching', function() + it('reads match ids off the query result', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local queryStub = stub(mw.ext.LiquipediaDB, 'lpdb', function() + return {{match2id = 'a'}, {match2id = 'b'}} + end) + local matchIds = MatchGroupUtil.fetchMatchIds{conditions = '[[x::y]]', limit = 5, order = 'date asc'} + queryStub:revert() + + assert.are_same({'a', 'b'}, matchIds) + assert.stub(queryStub).was.called_with('match2', { + conditions = '[[x::y]]', + limit = 5, + order = 'date asc', + query = 'match2id', + }) + end) + + it('defaults the match id query limit to 1000', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local params + local queryStub = stub(mw.ext.LiquipediaDB, 'lpdb', function(_, queryParams) + params = queryParams + return {} + end) + MatchGroupUtil.fetchMatchIds{conditions = '[[x::y]]'} + queryStub:revert() + assert.are_equal(1000, params.limit) + end) + + it('reads the records of a bracket from lpdb', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local queryStub = stub(mw.ext.LiquipediaDB, 'lpdb', function() return {{match2id = 'a'}} end) + local records = MatchGroupUtil.fetchMatchRecords('abcdefghij') + queryStub:revert() + + assert.are_same({{match2id = 'a'}}, records) + assert.stub(queryStub).was.called_with('match2', { + conditions = '([[namespace::0]] or [[namespace::>0]]) AND [[match2bracketid::abcdefghij]]', + order = 'match2id ASC', + limit = 5000, + }) + end) + + it('returns the finals together with its bracket reset match', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local Variables = require('Module:Variables') + local Json = require('Module:Json') + + local function record(matchId, bracketData) + return { + match2id = matchId, + date = '2022-01-05', + mode = 'team', + match2bracketdata = bracketData, + match2opponents = {{name = 'A', match2players = {}}, {name = 'B', match2players = {}}}, + match2games = {}, + } + end + + Variables.varDefine('match2bracket_resetbracket', Json.stringify{ + record('resetbracket_R01-M001', {type = 'bracket', header = 'Final', + bracketreset = 'resetbracket_RxMBR'}), + record('resetbracket_RxMBR', {type = 'bracket'}), + }) + + local finals, resetMatch = MatchGroupUtil.fetchMatchForBracketDisplay( + 'resetbracket', 'resetbracket_R01-M001') + + assert.is_truthy(finals) + assert.is_truthy(resetMatch) + assert.are_equal('resetbracket_R01-M001', finals.matchId) + assert.are_equal('resetbracket_RxMBR', assert(resetMatch).matchId) + end) + + it('prefers the page variable over lpdb', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local Variables = require('Module:Variables') + local queryStub = stub(mw.ext.LiquipediaDB, 'lpdb', {}) + Variables.varDefine('match2bracket_abcdefghij', '[{"match2id":"cached"}]') + local records = MatchGroupUtil.fetchMatchRecords('abcdefghij') + queryStub:revert() + + assert.are_same({{match2id = 'cached'}}, records) + assert.spy(queryStub).was.called(0) + end) + end) + describe('re-exports', function() + it('points every match and bracket model member at its implementation', function() + local MatchGroupUtil = require('Module:MatchGroup/Util') + local MatchUtil = require('Module:MatchGroup/Util/Match') + local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local Types = require('Module:MatchGroup/Util/Types') + + assert.are_equal(Types, MatchGroupUtil.types) + for _, model in ipairs({MatchUtil, BracketUtil}) do + for name, implementation in pairs(model) do + assert.are_equal(implementation, MatchGroupUtil[name], + 'MatchGroupUtil.' .. name .. ' does not re-export its implementation') + end + end + end) + end) +end) diff --git a/lua/wikis/commons/Match.lua b/lua/wikis/commons/Match.lua index 19fb80eb53a..35ac2198bd5 100644 --- a/lua/wikis/commons/Match.lua +++ b/lua/wikis/commons/Match.lua @@ -13,7 +13,7 @@ local FeatureFlag = Lua.import('Module:FeatureFlag') local Json = Lua.import('Module:Json') local Logic = Lua.import('Module:Logic') local Lpdb = Lua.import('Module:Lpdb') -local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') +local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') local PageVariableNamespace = Lua.import('Module:PageVariableNamespace') local Table = Lua.import('Module:Table') local Variables = Lua.import('Module:Variables') @@ -172,12 +172,12 @@ function Match.splitRecordsByType(match) return {} end - local gameRecordList = MatchGroupUtil.normalizeSubtype(match, 'map') + local gameRecordList = MatchGroupInputUtil.normalizeSubtype(match, 'map') Match._removeLegacySubobjectRecords(match, 'map') match.match2games = nil match.games = nil - local opponentRecordList = MatchGroupUtil.normalizeSubtype(match, 'opponent') + local opponentRecordList = MatchGroupInputUtil.normalizeSubtype(match, 'opponent') Match._removeLegacySubobjectRecords(match, 'opponent') match.match2opponents = nil match.opponents = nil diff --git a/lua/wikis/commons/MatchGroup/Coordinates.lua b/lua/wikis/commons/MatchGroup/Coordinates.lua index c525e62f0a9..aabea647eb8 100644 --- a/lua/wikis/commons/MatchGroup/Coordinates.lua +++ b/lua/wikis/commons/MatchGroup/Coordinates.lua @@ -69,6 +69,9 @@ function MatchGroupCoordinates.computeSections(bracket) if isNewSection then table.insert(sections, {}) end + -- TODO: the first section only opens on a match that carries a header, so a bracket whose + -- root match has none errors here on sections[0] being nil. Either open a section for the + -- first match unconditionally, or reject the bracket with a readable message. table.insert(sections[#sections], matchId) sectionIxs[matchId] = #sections end diff --git a/lua/wikis/commons/MatchGroup/Display/Bracket.lua b/lua/wikis/commons/MatchGroup/Display/Bracket.lua index 622cf6102a3..e5b035fc382 100644 --- a/lua/wikis/commons/MatchGroup/Display/Bracket.lua +++ b/lua/wikis/commons/MatchGroup/Display/Bracket.lua @@ -458,7 +458,7 @@ function BracketDisplay.NodeBody(props) local bracketResetMatch = match.bracketData.bracketResetMatchId and props.matchesById[match.bracketData.bracketResetMatchId] if bracketResetMatch then - match = Logic.wrapTryOrLog(MatchGroupUtil.mergeBracketResetMatch)(match, bracketResetMatch) + match = Logic.wrapTryOrLog(DisplayHelper.mergeBracketResetMatch)(match, bracketResetMatch) end -- Current match diff --git a/lua/wikis/commons/MatchGroup/Display/Helper.lua b/lua/wikis/commons/MatchGroup/Display/Helper.lua index 9d9b6357f58..49313e384dc 100644 --- a/lua/wikis/commons/MatchGroup/Display/Helper.lua +++ b/lua/wikis/commons/MatchGroup/Display/Helper.lua @@ -351,4 +351,30 @@ DisplayHelper.getGlobalConfig = FnUtil.memoize(function() return config end) +---Merges a grand finals match with results of its bracket reset match. +---@param match table +---@param bracketResetMatch table +---@return table +function DisplayHelper.mergeBracketResetMatch(match, bracketResetMatch) + local mergedMatch = Table.merge(match, { + opponents = {}, + games = Table.copy(match.games), + }) + + for ix, opponent in ipairs(match.opponents) do + local resetOpponent = bracketResetMatch.opponents[ix] + mergedMatch.opponents[ix] = Table.merge(opponent, { + score2 = resetOpponent.score, + status2 = resetOpponent.status, + placement2 = resetOpponent.placement, + }) + end + + for _, game in ipairs(bracketResetMatch.games) do + table.insert(mergedMatch.games, game) + end + + return mergedMatch +end + return DisplayHelper diff --git a/lua/wikis/commons/MatchGroup/Input.lua b/lua/wikis/commons/MatchGroup/Input.lua index a95c94ce982..f17ab2b4c7b 100644 --- a/lua/wikis/commons/MatchGroup/Input.lua +++ b/lua/wikis/commons/MatchGroup/Input.lua @@ -44,7 +44,7 @@ local VALID_GSL_GROUP_STYLES = { ---@param match table function MatchGroupInput._applyTournamentVarsToMaps(match) - for _, map in ipairs(MatchGroupUtil.normalizeSubtype(match, 'map')) do + for _, map in ipairs(MatchGroupInputUtil.normalizeSubtype(match, 'map')) do Table.mergeInto(map, MatchGroupInputUtil.getTournamentContext(map, match)) end end @@ -148,6 +148,9 @@ function MatchGroupInput.readMatchpage(bracketId, matchId, matchInput) matchArgs.bracketid = bracketId matchArgs.matchid = matchId local match = MatchGroupInput._processMatch(matchArgs, {isMatchPage = true}) + -- TODO: this repeats half of MatchGroupInputUtil.getStandaloneId, which builds the same + -- 'MATCH__' scheme. Change one and standalone match lookups silently + -- stop matching, so the two should share a single definition. match.bracketid = 'MATCH_' .. match.bracketid return {match} end @@ -239,7 +242,7 @@ function MatchGroupInput.readBracket(bracketId, args, options) bracketData.bracketreset = bracketData.bracketreset or '' if not bracketData.loweredges then - local opponents = MatchGroupUtil.normalizeSubtype(match, 'opponent') + local opponents = MatchGroupInputUtil.normalizeSubtype(match, 'opponent') bracketData.loweredges = Array.map( MatchGroupUtil.autoAssignLowerEdges(#bracketData.lowerMatchIds, #opponents), MatchGroupUtil.indexTableToRecord diff --git a/lua/wikis/commons/MatchGroup/Input/Util.lua b/lua/wikis/commons/MatchGroup/Input/Util.lua index c9f35d36b45..86308884b17 100644 --- a/lua/wikis/commons/MatchGroup/Input/Util.lua +++ b/lua/wikis/commons/MatchGroup/Input/Util.lua @@ -350,7 +350,7 @@ function MatchGroupInputUtil.readMvp(match, opponents) local players = mw.text.split(match.mvp, ',') -- parse the players to get their information - opponents = Logic.isNotDeepEmpty(opponents) and opponents or MatchGroupUtil.normalizeSubtype(match, 'opponent') + opponents = Logic.isNotDeepEmpty(opponents) and opponents or MatchGroupInputUtil.normalizeSubtype(match, 'opponent') local parsedPlayers = Array.map(players, function(player, playerIndex) local link = mw.ext.TeamLiquidIntegration.resolve_redirect(mw.text.split(player, '|')[1]):gsub(' ', '_') for _, opponent in ipairs(opponents) do @@ -1749,4 +1749,36 @@ function MatchGroupInputUtil.getMatchDate(matchParser, matchInput) return earliestGameDateStruct end +---Get subtypes (opponent, map) as a list +---@param match table +---@param type 'opponent'|'map' +---@return any[] +function MatchGroupInputUtil.normalizeSubtype(match, type) + local listNames + if type == 'opponent' then + listNames = {'match2opponents', 'opponents'} + elseif type == 'map' then + listNames = {'match2games', 'games'} + else + error('Invalid subtype: ' .. type) + end + for _, listName in ipairs(listNames) do + if match[listName] then + return match[listName] + end + end + + return {} +end + +---@param bracketid string? +---@param matchid string? +---@return string? +function MatchGroupInputUtil.getStandaloneId(bracketid, matchid) + if not matchid or not bracketid then + return nil + end + return 'MATCH_' .. bracketid .. '_' .. matchid +end + return MatchGroupInputUtil diff --git a/lua/wikis/commons/MatchGroup/Util.lua b/lua/wikis/commons/MatchGroup/Util.lua index 5ed56d1294e..f6cb820275e 100644 --- a/lua/wikis/commons/MatchGroup/Util.lua +++ b/lua/wikis/commons/MatchGroup/Util.lua @@ -8,377 +8,61 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') -local Date = Lua.import('Module:Date/Ext') -local Faction = Lua.import('Module:Faction') local FnUtil = Lua.import('Module:FnUtil') -local Info = Lua.import('Module:Info', {loadData = true}) local Json = Lua.import('Module:Json') -local Logic = Lua.import('Module:Logic') local Operator = Lua.import('Module:Operator') -local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') -local TypeUtil = Lua.import('Module:TypeUtil') local Variables = Lua.import('Module:Variables') local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') local WikiSpecific = Lua.import('Module:Brkts/WikiSpecific') -local NOW = os.time() - -local nilIfEmpty = String.nilIfEmpty +local BracketUtil = Lua.import('Module:MatchGroup/Util/Bracket') +local MatchUtil = Lua.import('Module:MatchGroup/Util/Match') +local Types = Lua.import('Module:MatchGroup/Util/Types') --[[ -Non-display utility functions for brackets, matchlists, matches, opponents, -games, and etc in the new bracket framework. - -Display related functions go in Module:MatchGroup/Display/Helper. -]] ----@class MatchGroupUtil -local MatchGroupUtil = {types = {}} - ----@class MatchGroupUtilLowerEdge ----@field lowerMatchIndex number ----@field opponentIndex number - -MatchGroupUtil.types.LowerEdge = TypeUtil.struct({ - lowerMatchIndex = 'number', - opponentIndex = 'number', -}) ----@alias AdvanceBg 'up'|'stayup'|'stay'|'staydown'|'down' -MatchGroupUtil.types.AdvanceBg = TypeUtil.literalUnion('up', 'stayup', 'stay', 'staydown', 'down') ----@class MatchGroupUtilAdvanceSpot ----@field bg AdvanceBg ----@field matchId string? ----@field type string? - -MatchGroupUtil.types.AdvanceSpot = TypeUtil.struct({ - bg = MatchGroupUtil.types.AdvanceBg, - matchId = 'string?', - type = TypeUtil.literalUnion('advance', 'custom', 'qualify'), -}) - ----@class MatchGroupUtilBracketBracketData ----@field coordinates MatchGroupUtilMatchCoordinates ----@field advanceSpots MatchGroupUtilAdvanceSpot[] ----@field bracketResetMatchId string? ----@field bracketType string? ----@field header string? ----@field inheritedHeader string? ----@field lowerEdges MatchGroupUtilLowerEdge[]? ----@field lowerMatchIds string[] ----@field qualLose boolean? ----@field qualLoseLiteral string? ----@field qualSkip number? ----@field qualWin boolean? ----@field qualWinLiteral string? ----@field skipRound number? ----@field thirdPlaceMatchId string? ----@field title string? ----@field type 'bracket' ----@field upperMatchId string? ----@field matchId string? ----@field matchPage string? ----@field qualifiedHeader string? - -MatchGroupUtil.types.BracketBracketData = TypeUtil.struct({ - advanceSpots = TypeUtil.array(MatchGroupUtil.types.AdvanceSpot), - bracketResetMatchId = 'string?', - bracketType = 'string?', - header = 'string?', - inheritedHeader = 'string?', - lowerEdges = TypeUtil.array(MatchGroupUtil.types.LowerEdge), - lowerMatchIds = TypeUtil.array('string'), - qualLose = 'boolean?', - qualLoseLiteral = 'string?', - qualSkip = 'number?', - qualWin = 'boolean?', - qualifiedHeader = 'string?', - qualWinLiteral = 'string?', - skipRound = 'number?', - thirdPlaceMatchId = 'string?', - title = 'string?', - type = TypeUtil.literal('bracket'), - upperMatchId = 'string?', -}) ----@class MatchGroupUtilMatchCoordinates ----@field depth number ----@field depthCount number ----@field matchIndexInRound number ----@field rootIndex number ----@field roundCount number ----@field roundIndex number ----@field sectionCount number ----@field sectionIndex number ----@field semanticDepth number ----@field semanticRoundIndex number - -MatchGroupUtil.types.MatchCoordinates = TypeUtil.struct({ - depth = 'number', - depthCount = 'number', - matchIndexInRound = 'number', - rootIndex = 'number', - roundCount = 'number', - roundIndex = 'number', - sectionCount = 'number', - sectionIndex = 'number', - semanticDepth = 'number', - semanticRoundIndex = 'number', -}) ----@class MatchGroupUtilMatchlistBracketData ----@field header string? ----@field title string? ----@field dateHeader boolean? ----@field type 'matchlist' ----@field matchId string? ----@field matchPage string? - -MatchGroupUtil.types.MatchlistBracketData = TypeUtil.struct({ - header = 'string?', - title = 'string?', - dateHeader = 'boolean?', - type = TypeUtil.literal('matchlist'), -}) ----@alias MatchGroupUtilBracketData MatchGroupUtilMatchlistBracketData|MatchGroupUtilBracketBracketData -MatchGroupUtil.types.BracketData = TypeUtil.union( - MatchGroupUtil.types.MatchlistBracketData, - MatchGroupUtil.types.BracketBracketData -) - ----@class standardPlayer ----@field displayName string? ----@field flag string? ----@field pageName string? ----@field team string? ----@field extradata table? ----@field pageIsResolved boolean? ----@field faction string? ----@field apiId string? - -MatchGroupUtil.types.Player = TypeUtil.struct({ - displayName = 'string?', - flag = 'string?', - pageName = 'string?', - team = 'string?', - extradata = 'table?', - pageIsResolved = 'boolean?', - faction = 'string?', -}) - ----@class standardOpponent ----@field advanceBg string? ----@field advances boolean? ----@field icon string? ----@field icondark string? ----@field name string? ----@field placement number? ----@field placement2 number? ----@field players standardPlayer[]? ----@field score number? ----@field scoreDisplay number? ----@field score2 number? ----@field status string? ----@field status2 string? ----@field template string? ----@field type OpponentType ----@field team string? ----@field extradata table - -MatchGroupUtil.types.Opponent = TypeUtil.struct({ - advanceBg = 'string?', - advances = 'boolean?', - icon = 'string?', - name = 'string?', - placement = 'number?', - placement2 = 'number?', - players = TypeUtil.array(MatchGroupUtil.types.Player), - score = 'number?', - score2 = 'number?', - status = 'string?', - status2 = 'string?', - template = 'string?', - type = 'string', - extradata = 'table', -}) - ----@class GameOpponent ----@field name string? ----@field players standardPlayer[] ----@field template string? ----@field type string - -MatchGroupUtil.types.GameOpponent = TypeUtil.struct({ - name = 'string?', - players = TypeUtil.optional(TypeUtil.array(MatchGroupUtil.types.Player)), - template = 'string?', - type = 'string', -}) - ----@alias MatchStatus 'notplayed'|''|nil -MatchGroupUtil.types.Status = TypeUtil.optional(TypeUtil.literalUnion('notplayed', '')) - ----@class MatchGroupUtilGame ----@field comment string? ----@field date string? ----@field dateIsExact boolean ----@field game string? ----@field header string? ----@field length string|number? ----@field map string? ----@field mapDisplayName string? ----@field mode string? ----@field opponents {players: table[], score: number?, status: string?}[] ----@field patch string? ----@field resultType string? ----@field scores number[] ----@field subgroup number? ----@field type string? ----@field vod string? ----@field winner integer? ----@field status string? ----@field extradata table? ----@field timestamp number ----@field timezoneId string? - -MatchGroupUtil.types.Game = TypeUtil.struct({ - comment = 'string?', - date = 'string?', - game = 'string?', - header = 'string?', - length = TypeUtil.optional(TypeUtil.union('number', 'string')), - map = 'string?', - mapDisplayName = 'string?', - mode = 'string?', - patch = 'string?', - resultType = 'string?', - scores = TypeUtil.array('number'), - subgroup = 'number?', - type = 'string?', - vod = 'string?', - winner = 'number?', - extradata = 'table?', -}) +Fetches match records and assembles them into matchlists and brackets. ----@class MatchGroupUtilMatch ----@field bracketData MatchGroupUtilBracketData ----@field comment string? ----@field date string ----@field dateIsExact boolean ----@field finished boolean ----@field game string? ----@field games MatchGroupUtilGame[] ----@field icon string? ----@field iconDark string? ----@field links table ----@field liquipediatier string? # TODO: camelCase ----@field liquipediatiertype string? # TODO: camelCase ----@field matchId string? ----@field mode string? ----@field opponents standardOpponent[] ----@field pageName string? ----@field parent string? ----@field patch string? ----@field phase 'upcoming'|'ongoing'|'finished' ----@field publisherTier string? ----@field resultType string? ----@field section string? ----@field series string? ----@field shortname string? ----@field status MatchStatus ----@field stream table ----@field tickername string? ----@field tournament string? ----@field type string? ----@field vod string? ----@field winner number? ----@field extradata table? ----@field timestamp number ----@field timezoneId string? ----@field bestof number? +The pieces it assembles from live elsewhere: the match model in Module:MatchGroup/Util/Match, the +bracket model in Module:MatchGroup/Util/Bracket, and the shapes of both in +Module:MatchGroup/Util/Types. Display related functions go in Module:MatchGroup/Display/Helper. -MatchGroupUtil.types.Match = TypeUtil.struct({ - bracketData = MatchGroupUtil.types.BracketData, - comment = 'string?', - date = 'string', - dateIsExact = 'boolean', - finished = 'boolean', - game = 'string?', - games = TypeUtil.array(MatchGroupUtil.types.Game), - icon = 'string?', - iconDark = 'string?', - links = 'table', - liquipediatier = 'string?', - liquipediatiertype = 'string?', - matchId = 'string?', - mode = 'string', - opponents = TypeUtil.array(MatchGroupUtil.types.Opponent), - pageName = 'string?', - parent = 'string?', - patch = 'string?', - publisherTier = 'string?', - resultType = 'string?', - section = 'string?', - series = 'string?', - shortname = 'string?', - status = MatchGroupUtil.types.Status, - stream = 'table', - tickername = 'string?', - tournament = 'string?', - type = 'string?', - vod = 'string?', - winner = 'number?', - extradata = 'table?', -}) +Both models are also re-exported here, as a complete mirror: every member of either model has a +re-export, which match_group_util_spec asserts. Prefer importing the models directly. The mirror +exists so that modules written against the combined interface keep working until they are moved +over, and nothing that is not a model member should be added to it. ----@class MatchGroupUtilSubgroup ----@field games MatchGroupUtilGame[] ----@field subgroup number ----@field header string? - ----@class FFAMatchGroupUtilMatch: MatchGroupUtilMatch ----@field games FFAMatchGroupUtilGame[] - ----@class FFAMatchGroupUtilGame: MatchGroupUtilGame ----@field stream table - ----@class MatchGroupUtilMatchlist ----@field bracketDatasById table ----@field matches MatchGroupUtilMatch[] ----@field matchesById table ----@field type 'matchlist' - -MatchGroupUtil.types.Matchlist = TypeUtil.struct({ - bracketDatasById = TypeUtil.table('string', MatchGroupUtil.types.BracketData), - matches = TypeUtil.array(MatchGroupUtil.types.Match), - matchesById = TypeUtil.table('string', MatchGroupUtil.types.Match), - type = TypeUtil.literal('matchlist'), -}) - ----@class MatchGroupUtilBracket ----@field bracketDatasById table ----@field coordinatesByMatchId table ----@field matches MatchGroupUtilMatch[] ----@field matchesById table ----@field rootMatchIds string[] ----@field rounds string[][] ----@field sections string[][] ----@field type 'bracket' - -MatchGroupUtil.types.Bracket = TypeUtil.struct({ - bracketDatasById = TypeUtil.table('string', MatchGroupUtil.types.BracketData), - coordinatesByMatchId = TypeUtil.table('string', MatchGroupUtil.types.MatchCoordinates), - matches = TypeUtil.array(MatchGroupUtil.types.Match), - matchesById = TypeUtil.table('string', MatchGroupUtil.types.Match), - rootMatchIds = TypeUtil.array('string'), - rounds = TypeUtil.array(TypeUtil.array('string')), - sections = TypeUtil.array(TypeUtil.array('string')), - type = TypeUtil.literal('bracket'), -}) - ----@alias MatchGroupUtilMatchGroup MatchGroupUtilBracket|MatchGroupUtilMatchlist -MatchGroupUtil.types.MatchGroup = TypeUtil.union( - MatchGroupUtil.types.Matchlist, - MatchGroupUtil.types.Bracket -) +-- TODO: delete the re-exports once no module outside MatchGroup/ imports this for the models. +]] +---@class MatchGroupUtil +local MatchGroupUtil = {types = Types} + +--- Re-exported from Module:MatchGroup/Util/Match. Prefer importing that module directly. +MatchGroupUtil.matchFromRecord = MatchUtil.matchFromRecord +MatchGroupUtil.opponentFromRecord = MatchUtil.opponentFromRecord +MatchGroupUtil.createOpponent = MatchUtil.createOpponent +MatchGroupUtil.playerFromRecord = MatchUtil.playerFromRecord +MatchGroupUtil.gameFromRecord = MatchUtil.gameFromRecord +MatchGroupUtil.groupBySubgroup = MatchUtil.groupBySubgroup +MatchGroupUtil.computeMatchPhase = MatchUtil.computeMatchPhase + +--- Re-exported from Module:MatchGroup/Util/Bracket. Prefer importing that module directly. +MatchGroupUtil.splitMatchId = BracketUtil.splitMatchId +MatchGroupUtil.matchIdToKey = BracketUtil.matchIdToKey +MatchGroupUtil.matchIdFromKey = BracketUtil.matchIdFromKey +MatchGroupUtil.bracketDataFromRecord = BracketUtil.bracketDataFromRecord +MatchGroupUtil.bracketDataToRecord = BracketUtil.bracketDataToRecord +MatchGroupUtil.computeLowerMatchIdsFromLegacy = BracketUtil.computeLowerMatchIdsFromLegacy +MatchGroupUtil.autoAssignLowerEdges = BracketUtil.autoAssignLowerEdges +MatchGroupUtil.computeAdvanceSpots = BracketUtil.computeAdvanceSpots +MatchGroupUtil.populateAdvanceSpots = BracketUtil.populateAdvanceSpots +MatchGroupUtil.computeRootMatchIds = BracketUtil.computeRootMatchIds +MatchGroupUtil.backfillUpperMatchIds = BracketUtil.backfillUpperMatchIds +MatchGroupUtil.backfillCoordinates = BracketUtil.backfillCoordinates +MatchGroupUtil.indexTableFromRecord = BracketUtil.indexTableFromRecord +MatchGroupUtil.indexTableToRecord = BracketUtil.indexTableToRecord +MatchGroupUtil.sectionIndexToString = BracketUtil.sectionIndexToString ---Fetches all match ids of matches that satisfy the supplied condition ---@param props {conditions: string|AbstractConditionNode, limit: string|integer?, order: string?} @@ -484,49 +168,6 @@ function MatchGroupUtil.makeBracketFromRecords(matchRecords) return bracket end ----Returns an array of all the IDs of root matches. The matches are sorted in display order. ----@param bracketDatasById table ----@return string[] -function MatchGroupUtil.computeRootMatchIds(bracketDatasById) - -- Matches without upper matches - local rootMatchIds = {} - for matchId, bracketData in pairs(bracketDatasById) do - if not bracketData.upperMatchId - and not String.endsWith(matchId, 'RxMBR') then - table.insert(rootMatchIds, matchId) - end - end - - Array.sortInPlaceBy(rootMatchIds, function(matchId) - local coordinates = bracketDatasById[matchId].coordinates - return coordinates and {coordinates.rootIndex} or {-1, matchId} - end) - - return rootMatchIds -end - ----Populate bracketData.upperMatchId if it is missing. This can happen if the bracket template is missing data. ----@param bracketDatasById table -function MatchGroupUtil.backfillUpperMatchIds(bracketDatasById) - local upperMatchIds = MatchGroupCoordinates.computeUpperMatchIds(bracketDatasById) - - for matchId, bracketData in pairs(bracketDatasById) do - bracketData.upperMatchId = upperMatchIds[matchId] - end -end - ----Populate bracketData.coordinates if it is missing. ----This can happen if the bracket template has not been recently purged. ----@param matchGroup MatchGroupUtilBracket -function MatchGroupUtil.backfillCoordinates(matchGroup) - local bracketCoordinates = MatchGroupCoordinates.computeCoordinates(matchGroup) - - Table.mergeInto(matchGroup, bracketCoordinates) - for matchId, bracketData in pairs(matchGroup.bracketDatasById) do - bracketData.coordinates = bracketCoordinates.coordinatesByMatchId[matchId] - end -end - ---Fetches all matches in a matchlist or bracket. ---Returns a list of structurally typed matches lexicographically ordered by matchId. ---@param bracketId string @@ -551,527 +192,4 @@ function MatchGroupUtil.fetchMatchForBracketDisplay(bracketId, matchId) return match, bracketResetMatch end ----Converts a match record to a structurally typed table with the appropriate data types for field values. ----The match record is either a match created in the store bracket codepath (WikiSpecific.processMatch), ----or a record fetched from LPDB (MatchGroupUtil.fetchMatchRecords). ----The returned match struct is used in various display components (Bracket, MatchSummary, etc) ---- ----This is the implementation used on wikis by default. Wikis may specify a different conversion by setting ----WikiSpecific.matchFromRecord. Refer to the starcraft2 wiki as an example. ----@param record match2 ----@return MatchGroupUtilMatch -function MatchGroupUtil.matchFromRecord(record) - local extradata = MatchGroupUtil.parseOrCopyExtradata(record.extradata) - local opponents = Array.map(record.match2opponents, FnUtil.curry(MatchGroupUtil.opponentFromRecord, record)) - local games = Array.map(record.match2games, function(game) return MatchGroupUtil.gameFromRecord(game, #opponents) end) - local bracketData = MatchGroupUtil.bracketDataFromRecord(Json.parseIfString(record.match2bracketdata)) - if bracketData.type == 'bracket' then - bracketData.lowerEdges = bracketData.lowerEdges - or MatchGroupUtil.autoAssignLowerEdges(#bracketData.lowerMatchIds, #opponents) - end - - local walkover = nilIfEmpty(record.walkover) - - local match = { - bestof = tonumber(record.bestof) or 0, - bracketData = bracketData, - comment = nilIfEmpty(Table.extract(extradata, 'comment')), - extradata = extradata, - date = record.date, - dateIsExact = Logic.readBool(record.dateexact), - finished = Logic.readBool(record.finished), - game = record.game, - games = games, - icon = nilIfEmpty(record.icon), - iconDark = nilIfEmpty(record.icondark), - links = Json.parseIfString(record.links) or {}, - matchId = record.match2id, - liquipediatier = record.liquipediatier, - liquipediatiertype = record.liquipediatiertype, - mode = record.mode, - opponents = opponents, - pageName = record.pagename, - parent = record.parent, - patch = record.patch, - publisherTier = nilIfEmpty(record.publishertier), - resultType = nilIfEmpty(record.resulttype), - section = nilIfEmpty(record.section), - series = nilIfEmpty(record.series), - shortname = nilIfEmpty(record.shortname), - status = nilIfEmpty(record.status), - stream = Json.parseIfString(record.stream) or {}, - tickername = record.tickername, - timestamp = tonumber(Table.extract(extradata, 'timestamp')), - timezoneId = Table.extract(extradata, 'timezoneid'), - tournament = record.tournament, - type = nilIfEmpty(record.type) or 'literal', - vod = nilIfEmpty(record.vod), - walkover = walkover and walkover:lower() or nil, - winner = tonumber(record.winner), - } - - match.phase = MatchGroupUtil.computeMatchPhase(match) - - return match -end - ----@param data table? ----@return MatchGroupUtilBracketData -function MatchGroupUtil.bracketDataFromRecord(data) - if not data then - return {} - end - if data.type == 'bracket' then - local advanceSpots = data.advancespots or MatchGroupUtil.computeAdvanceSpots(data) - return { - advanceSpots = advanceSpots, - bracketResetMatchId = nilIfEmpty(data.bracketreset), - coordinates = data.coordinates and MatchGroupUtil.indexTableFromRecord(data.coordinates), - header = nilIfEmpty(data.header), - inheritedHeader = nilIfEmpty(data.inheritedheader), - lowerEdges = data.loweredges and Array.map(data.loweredges, MatchGroupUtil.indexTableFromRecord), - lowerMatchIds = data.lowerMatchIds or MatchGroupUtil.computeLowerMatchIdsFromLegacy(data), - qualifiedHeader = nilIfEmpty(data.qualifiedheader), - qualLose = advanceSpots[2] and advanceSpots[2].type == 'qualify', - qualLoseLiteral = nilIfEmpty(data.qualloseLiteral), - qualSkip = tonumber(data.qualskip) or data.qualskip == 'true' and 1 or 0, - qualWin = advanceSpots[1] and advanceSpots[1].type == 'qualify', - qualWinLiteral = nilIfEmpty(data.qualwinLiteral), - matchPage = nilIfEmpty(data.matchpage), - skipRound = tonumber(data.skipround) or data.skipround == 'true' and 1 or 0, - bracketType = nilIfEmpty(data.bracketType), - thirdPlaceMatchId = nilIfEmpty(data.thirdplace), - type = 'bracket', - upperMatchId = nilIfEmpty(data.upperMatchId), - } - else - return { - dateHeader = nilIfEmpty(data.dateheader), - header = nilIfEmpty(data.header), - inheritedHeader = nilIfEmpty(data.inheritedheader), - matchIndex = nilIfEmpty(data.matchIndex), - matchPage = nilIfEmpty(data.matchpage), - title = nilIfEmpty(data.title), - type = 'matchlist', - } - end -end - ----@param bracketData MatchGroupUtilBracketData ----@return table -function MatchGroupUtil.bracketDataToRecord(bracketData) - local coordinates = bracketData.coordinates - return { - bracketreset = bracketData.bracketResetMatchId, - bracketsection = coordinates - and MatchGroupUtil.sectionIndexToString(coordinates.sectionIndex, coordinates.sectionCount), - coordinates = coordinates and MatchGroupUtil.indexTableToRecord(coordinates), - header = bracketData.header, - lowerMatchIds = bracketData.lowerMatchIds, - loweredges = bracketData.lowerEdges and Array.map(bracketData.lowerEdges, MatchGroupUtil.indexTableToRecord), - quallose = bracketData.qualLose and 'true' or nil, - qualloseLiteral = bracketData.qualLoseLiteral, - qualskip = bracketData.qualSkip ~= 0 and bracketData.qualSkip or nil, - qualwin = bracketData.qualWin and 'true' or nil, - qualwinLiteral = bracketData.qualWinLiteral, - skipround = bracketData.skipRound ~= 0 and bracketData.skipRound or nil, - bracketType = bracketData.bracketType, - thirdplace = bracketData.thirdPlaceMatchId, - tolower = bracketData.lowerMatchIds[#bracketData.lowerMatchIds], - toupper = bracketData.lowerMatchIds[#bracketData.lowerMatchIds - 1], - type = bracketData.type, - upperMatchId = bracketData.upperMatchId, - } -end - ----@param matchRecord match2 ----@param record match2opponent ----@param opponentIndex integer ----@return standardOpponent -function MatchGroupUtil.opponentFromRecord(matchRecord, record, opponentIndex) - local extradata = MatchGroupUtil.parseOrCopyExtradata(record.extradata) - local score = record.score - local status = record.status - local bestof = tonumber(matchRecord.bestof) - local game1 = (matchRecord.match2games or {})[1] - local hasOnlyScores = Array.all(matchRecord.match2opponents, function(opponent) - return opponent.status == 'S' end) - local scoreDisplay = nil - if bestof == 1 and Info.config.match2.gameScoresIfBo1 and game1 and hasOnlyScores then - local mapOpponent = (game1.opponents or {})[opponentIndex] or {} - scoreDisplay = tonumber(mapOpponent.score) - status = mapOpponent.status - end - - return { - advanceBg = nilIfEmpty(Table.extract(extradata, 'bg')), - advances = Logic.readBoolOrNil(Table.extract(extradata, 'advances')), - extradata = extradata, - icon = nilIfEmpty(record.icon), - name = nilIfEmpty(record.name), - placement = tonumber(record.placement), - players = Array.map(record.match2players, MatchGroupUtil.playerFromRecord), - score = tonumber(score), - scoreDisplay = scoreDisplay, - status = status, - template = nilIfEmpty(record.template), - type = nilIfEmpty(record.type) or 'literal', - } -end - ----@param args table ----@return standardOpponent -function MatchGroupUtil.createOpponent(args) - return { - extradata = args.extradata or {}, - icon = args.icon, - name = args.name, - placement = args.placement, - players = args.players or {}, - score = args.score, - status = args.status, - template = args.template, - type = args.type or 'literal', - } -end - ----@param record table ----@return standardPlayer -function MatchGroupUtil.playerFromRecord(record) - local extradata = MatchGroupUtil.parseOrCopyExtradata(record.extradata) - local faction = Faction.read(extradata.faction) - return { - displayName = record.displayname, - extradata = extradata, - flag = nilIfEmpty(record.flag), - pageName = record.name, - team = Table.extract(extradata, 'playerteam'), - faction = faction or Faction.defaultFaction, - pageIsResolved = Logic.isNotEmpty(faction), - } -end - ----@param record match2game ----@param opponentCount integer? ----@return MatchGroupUtilGame -function MatchGroupUtil.gameFromRecord(record, opponentCount) - local extradata = MatchGroupUtil.parseOrCopyExtradata(record.extradata) - - return { - comment = nilIfEmpty(Table.extract(extradata, 'comment')), - date = record.date, - dateIsExact = nilIfEmpty(Table.extract(extradata, 'dateexact')), - extradata = extradata, - game = record.game, - header = nilIfEmpty(Table.extract(extradata, 'header')), - length = record.length, - map = nilIfEmpty(record.map), - mapDisplayName = nilIfEmpty(Table.extract(extradata, 'displayname')), - mode = nilIfEmpty(record.mode), - opponents = record.opponents, - patch = record.patch, - resultType = nilIfEmpty(record.resulttype), - status = nilIfEmpty(record.status), - scores = Json.parseIfString(record.scores) or {}, - subgroup = tonumber(record.subgroup), - timestamp = tonumber(Table.extract(extradata, 'timestamp')), - timezoneId = Table.extract(extradata, 'timezoneid'), - type = nilIfEmpty(record.type), - vod = nilIfEmpty(record.vod), - walkover = nilIfEmpty(record.walkover) and record.walkover:lower() or nil, - winner = tonumber(record.winner), - } -end - ----Group games on the subgroup field to form submatches ----@param match MatchGroupUtilMatch ----@return MatchGroupUtilSubgroup[] -function MatchGroupUtil.groupBySubgroup(match) - local previousSubgroup = nil - ---@type MatchGroupUtilGame[]? - local currentGames = nil - ---@type MatchGroupUtilGame[][] - local submatchGames = {} - Array.forEach(match.games, function (game) - if previousSubgroup == nil or previousSubgroup ~= game.subgroup then - currentGames = {} - Array.appendWith(submatchGames, currentGames) - previousSubgroup = game.subgroup - end - ---@cast currentGames -nil - Array.appendWith(currentGames, game) - end) - return Array.map(submatchGames, function (games, groupIndex) - ---@type MatchGroupUtilSubgroup - return { - games = games, - subgroup = groupIndex, - header = Table.extract(match.extradata or {}, 'subgroup' .. groupIndex .. 'header'), - } - end) -end - ----@param data table ----@return string[] -function MatchGroupUtil.computeLowerMatchIdsFromLegacy(data) - local lowerMatchIds = {} - if nilIfEmpty(data.toupper) then - table.insert(lowerMatchIds, data.toupper) - end - if nilIfEmpty(data.tolower) then - table.insert(lowerMatchIds, data.tolower) - end - return lowerMatchIds -end - ----Auto compute lower edges, which encode the connector lines between lower matches and this match. ----@param lowerMatchCount integer ----@param opponentCount integer ----@return {lowerMatchIndex: integer, opponentIndex: integer}[] -function MatchGroupUtil.autoAssignLowerEdges(lowerMatchCount, opponentCount) - local lowerEdges = {} - if lowerMatchCount <= opponentCount then - -- More opponents than lower matches: connect lower matches to opponents near the middle. - local skip = math.ceil((opponentCount - lowerMatchCount) / 2) - for lowerMatchIndex = 1, lowerMatchCount do - table.insert(lowerEdges, { - lowerMatchIndex = lowerMatchIndex, - opponentIndex = lowerMatchIndex + skip, - }) - end - else - -- More lower matches than opponents: The excess lower matches are all connected to the final opponent. - for lowerMatchIndex = 1, lowerMatchCount do - table.insert(lowerEdges, { - lowerMatchIndex = lowerMatchIndex, - opponentIndex = math.min(lowerMatchIndex, opponentCount), - }) - end - end - return lowerEdges -end - ----Computes just the advance spots that can be determined from a match bracket data. ----More are found in populateAdvanceSpots. ----@param data table ----@return table<1|2, {bg: string, type: string, matchId: string}> -function MatchGroupUtil.computeAdvanceSpots(data) - local advanceSpots = {} - - if data.upperMatchId then - advanceSpots[1] = {bg = 'up', type = 'advance', matchId = data.upperMatchId} - end - - if nilIfEmpty(data.winnerto) then - advanceSpots[1] = {bg = 'up', type = 'custom', matchId = data.winnerto} - end - if nilIfEmpty(data.loserto) then - advanceSpots[2] = {bg = 'stayup', type = 'custom', matchId = data.loserto} - end - - if Logic.readBool(data.qualwin) then - advanceSpots[1] = Table.merge(advanceSpots[1], {bg = 'up', type = 'qualify'}) - end - if Logic.readBool(data.quallose) then - advanceSpots[2] = Table.merge(advanceSpots[2], {bg = 'stayup', type = 'qualify'}) - end - - return advanceSpots -end - ----@param bracket MatchGroupUtilBracket -function MatchGroupUtil.populateAdvanceSpots(bracket) - if #bracket.matches == 0 then - return - end - - -- Loser of semifinals play in third place match - local firstBracketData = bracket.bracketDatasById[bracket.rootMatchIds[1]] - local thirdPlaceMatchId = firstBracketData.thirdPlaceMatchId - if thirdPlaceMatchId and bracket.matchesById[thirdPlaceMatchId] then - for _, lowerMatchId in ipairs(firstBracketData.lowerMatchIds) do - local bracketData = bracket.bracketDatasById[lowerMatchId] - bracketData.advanceSpots[2] = bracketData.advanceSpots[2] - or {bg = 'stayup', type = 'advance', matchId = thirdPlaceMatchId} - end - end - - -- Custom advance spots set via pbg params - for _, match in ipairs(bracket.matches) do - local pbgs = Array.mapIndexes(function(ix) - return Table.extract(match.extradata, 'pbg' .. ix) - end) - for i = 1, #pbgs do - match.bracketData.advanceSpots[i] = Table.merge( - match.bracketData.advanceSpots[i], - {bg = pbgs[i], type = 'custom'} - ) - end - end -end - ----Merges a grand finals match with results of its bracket reset match. ----@param match table ----@param bracketResetMatch table ----@return table -function MatchGroupUtil.mergeBracketResetMatch(match, bracketResetMatch) - local mergedMatch = Table.merge(match, { - opponents = {}, - games = Table.copy(match.games), - }) - - for ix, opponent in ipairs(match.opponents) do - local resetOpponent = bracketResetMatch.opponents[ix] - mergedMatch.opponents[ix] = Table.merge(opponent, { - score2 = resetOpponent.score, - status2 = resetOpponent.status, - placement2 = resetOpponent.placement, - }) - end - - for _, game in ipairs(bracketResetMatch.games) do - table.insert(mergedMatch.games, game) - end - - return mergedMatch -end - ----Parse extradata as a JSON string if read from page variables. Otherwise create a copy if fetched from lpdb. ----The returned extradata table can then be mutated without altering the source. ----@param recordExtradata table|string? ----@return table -function MatchGroupUtil.parseOrCopyExtradata(recordExtradata) - return type(recordExtradata) == 'string' and Json.parse(recordExtradata) - or type(recordExtradata) == 'table' and Table.copy(recordExtradata) - or {} -end - ----Convert 0-based indexes to 1-based ----@param record table ----@return table -function MatchGroupUtil.indexTableFromRecord(record) - return Table.map(record, function(key, value) - if key:match('Index') and type(value) == 'number' then - return key, value + 1 - else - return key, value - end - end) -end - ----Convert 1-based indexes to 0-based ----@param coordinates table ----@return table -function MatchGroupUtil.indexTableToRecord(coordinates) - return Table.map(coordinates, function(key, value) - if key:match('Index') and type(value) == 'number' then - return key, value - 1 - else - return key, value - end - end) -end - ----@param sectionIndex integer ----@param sectionCount integer ----@return string -function MatchGroupUtil.sectionIndexToString(sectionIndex, sectionCount) - if sectionIndex == 1 then - return 'upper' - elseif sectionIndex == sectionCount then - return 'lower' - else - return 'mid' - end -end - ----Splits a matchId like h5HXaqbSVP_R02-M002 into the bracket ID h5HXaqbSVP and the base match ID R02-M002. ----@param matchId string ----@return string?, string? -function MatchGroupUtil.splitMatchId(matchId) - return matchId:match('^(.-)_([%w-]+)$') -end - ----Converts R01-M003 to R1M3 ----@param matchId string ----@return string -function MatchGroupUtil.matchIdToKey(matchId) - if matchId == 'RxMBR' or matchId == 'RxMTP' then - return matchId - end - local round, matchInRound = matchId:match('^R(%d+)%-M(%d+)$') - return 'R' .. tonumber(round) .. 'M' .. tonumber(matchInRound) -end - ----Converts R1M3 to R01-M003 ----@param matchKey string ----@return string -function MatchGroupUtil.matchIdFromKey(matchKey) - if matchKey == 'RxMBR' or matchKey == 'RxMTP' then - return matchKey - end - local round, matchInRound = matchKey:match('^R(%d+)M(%d+)$') - if round and matchInRound then - -- Bracket format - return 'R' .. string.format('%02d', round) .. '-M' .. string.format('%03d', matchInRound) - else - -- Matchlist format - return string.format('%04d', matchKey) - end -end - ----@param matchid string? ----@param bracketid string? ----@return string? -function MatchGroupUtil.getStandaloneId(bracketid, matchid) - if not matchid or not bracketid then - return nil - end - return 'MATCH_' .. bracketid .. '_' .. matchid -end - ----@class PartialMatchGameRecord ----@field date string ----@field dateexact boolean? ----@field timestamp number? ----@field finished boolean? ----@field winner integer? - ----Determines the phase of a match based on its properties. ----@param match MatchGroupUtilMatch|MatchGroupUtilGame|PartialMatchGameRecord ----@return 'finished'|'ongoing'|'upcoming' -function MatchGroupUtil.computeMatchPhase(match) - local isExact = Logic.readBoolOrNil(match.dateIsExact or match.dateexact) - local matchStartTimestamp = match.timestamp or Date.readTimestampOrNil(match.date) or Date.defaultTimestamp - if match.winner or Logic.readBool(match.finished) then - return 'finished' - elseif isExact ~= false and matchStartTimestamp ~= Date.defaultTimestamp and matchStartTimestamp <= NOW then - return 'ongoing' - else - return 'upcoming' - end -end - ----Get subtypes (opponent, map) as a list ----@param match table ----@param type 'opponent'|'map' ----@return any[] -function MatchGroupUtil.normalizeSubtype(match, type) - local listNames - if type == 'opponent' then - listNames = {'match2opponents', 'opponents'} - elseif type == 'map' then - listNames = {'match2games', 'games'} - else - error('Invalid subtype: ' .. type) - end - for _, listName in ipairs(listNames) do - if match[listName] then - return match[listName] - end - end - - return {} -end - return MatchGroupUtil diff --git a/lua/wikis/commons/MatchGroup/Util/Bracket.lua b/lua/wikis/commons/MatchGroup/Util/Bracket.lua new file mode 100644 index 00000000000..7127d3109db --- /dev/null +++ b/lua/wikis/commons/MatchGroup/Util/Bracket.lua @@ -0,0 +1,315 @@ +--- +-- @Liquipedia +-- page=Module:MatchGroup/Util/Bracket +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local Logic = Lua.import('Module:Logic') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') + +local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') + +local nilIfEmpty = String.nilIfEmpty + +--[[ +Where a match sits in its match group and how that group is laid out. Reads and writes bracket data +records, works out lower edges, advance spots, root matches and coordinates, and owns the id +namespace that addresses a match within its group. + +A matchlist counts as a bracket here: it is the flat case of the same structure, which is why the +matchlist id form lives alongside the bracket ones. +]] +local BracketUtil = {} + +---@param data table? +---@return MatchGroupUtilBracketData +function BracketUtil.bracketDataFromRecord(data) + if not data then + return {} + end + if data.type == 'bracket' then + local advanceSpots = data.advancespots or BracketUtil.computeAdvanceSpots(data) + return { + advanceSpots = advanceSpots, + bracketResetMatchId = nilIfEmpty(data.bracketreset), + coordinates = data.coordinates and BracketUtil.indexTableFromRecord(data.coordinates), + header = nilIfEmpty(data.header), + inheritedHeader = nilIfEmpty(data.inheritedheader), + lowerEdges = data.loweredges and Array.map(data.loweredges, BracketUtil.indexTableFromRecord), + lowerMatchIds = data.lowerMatchIds or BracketUtil.computeLowerMatchIdsFromLegacy(data), + qualifiedHeader = nilIfEmpty(data.qualifiedheader), + qualLose = advanceSpots[2] and advanceSpots[2].type == 'qualify', + qualLoseLiteral = nilIfEmpty(data.qualloseLiteral), + qualSkip = tonumber(data.qualskip) or data.qualskip == 'true' and 1 or 0, + qualWin = advanceSpots[1] and advanceSpots[1].type == 'qualify', + qualWinLiteral = nilIfEmpty(data.qualwinLiteral), + matchPage = nilIfEmpty(data.matchpage), + skipRound = tonumber(data.skipround) or data.skipround == 'true' and 1 or 0, + bracketType = nilIfEmpty(data.bracketType), + thirdPlaceMatchId = nilIfEmpty(data.thirdplace), + type = 'bracket', + upperMatchId = nilIfEmpty(data.upperMatchId), + } + else + return { + dateHeader = nilIfEmpty(data.dateheader), + header = nilIfEmpty(data.header), + inheritedHeader = nilIfEmpty(data.inheritedheader), + matchIndex = nilIfEmpty(data.matchIndex), + matchPage = nilIfEmpty(data.matchpage), + title = nilIfEmpty(data.title), + type = 'matchlist', + } + end +end + +---@param bracketData MatchGroupUtilBracketData +---@return table +function BracketUtil.bracketDataToRecord(bracketData) + local coordinates = bracketData.coordinates + return { + bracketreset = bracketData.bracketResetMatchId, + bracketsection = coordinates + and BracketUtil.sectionIndexToString(coordinates.sectionIndex, coordinates.sectionCount), + coordinates = coordinates and BracketUtil.indexTableToRecord(coordinates), + header = bracketData.header, + lowerMatchIds = bracketData.lowerMatchIds, + loweredges = bracketData.lowerEdges and Array.map(bracketData.lowerEdges, BracketUtil.indexTableToRecord), + quallose = bracketData.qualLose and 'true' or nil, + qualloseLiteral = bracketData.qualLoseLiteral, + qualskip = bracketData.qualSkip ~= 0 and bracketData.qualSkip or nil, + qualwin = bracketData.qualWin and 'true' or nil, + qualwinLiteral = bracketData.qualWinLiteral, + skipround = bracketData.skipRound ~= 0 and bracketData.skipRound or nil, + bracketType = bracketData.bracketType, + thirdplace = bracketData.thirdPlaceMatchId, + tolower = bracketData.lowerMatchIds[#bracketData.lowerMatchIds], + toupper = bracketData.lowerMatchIds[#bracketData.lowerMatchIds - 1], + type = bracketData.type, + upperMatchId = bracketData.upperMatchId, + } +end + +---@param data table +---@return string[] +function BracketUtil.computeLowerMatchIdsFromLegacy(data) + local lowerMatchIds = {} + if nilIfEmpty(data.toupper) then + table.insert(lowerMatchIds, data.toupper) + end + if nilIfEmpty(data.tolower) then + table.insert(lowerMatchIds, data.tolower) + end + return lowerMatchIds +end + +---Auto compute lower edges, which encode the connector lines between lower matches and this match. +---@param lowerMatchCount integer +---@param opponentCount integer +---@return {lowerMatchIndex: integer, opponentIndex: integer}[] +function BracketUtil.autoAssignLowerEdges(lowerMatchCount, opponentCount) + local lowerEdges = {} + if lowerMatchCount <= opponentCount then + -- More opponents than lower matches: connect lower matches to opponents near the middle. + local skip = math.ceil((opponentCount - lowerMatchCount) / 2) + for lowerMatchIndex = 1, lowerMatchCount do + table.insert(lowerEdges, { + lowerMatchIndex = lowerMatchIndex, + opponentIndex = lowerMatchIndex + skip, + }) + end + else + -- More lower matches than opponents: The excess lower matches are all connected to the final opponent. + for lowerMatchIndex = 1, lowerMatchCount do + table.insert(lowerEdges, { + lowerMatchIndex = lowerMatchIndex, + opponentIndex = math.min(lowerMatchIndex, opponentCount), + }) + end + end + return lowerEdges +end + +---Computes just the advance spots that can be determined from a match bracket data. +---More are found in populateAdvanceSpots. +---@param data table +---@return table<1|2, {bg: string, type: string, matchId: string}> +function BracketUtil.computeAdvanceSpots(data) + local advanceSpots = {} + + if data.upperMatchId then + advanceSpots[1] = {bg = 'up', type = 'advance', matchId = data.upperMatchId} + end + + if nilIfEmpty(data.winnerto) then + advanceSpots[1] = {bg = 'up', type = 'custom', matchId = data.winnerto} + end + if nilIfEmpty(data.loserto) then + advanceSpots[2] = {bg = 'stayup', type = 'custom', matchId = data.loserto} + end + + if Logic.readBool(data.qualwin) then + advanceSpots[1] = Table.merge(advanceSpots[1], {bg = 'up', type = 'qualify'}) + end + if Logic.readBool(data.quallose) then + advanceSpots[2] = Table.merge(advanceSpots[2], {bg = 'stayup', type = 'qualify'}) + end + + return advanceSpots +end + +---@param bracket MatchGroupUtilBracket +function BracketUtil.populateAdvanceSpots(bracket) + if #bracket.matches == 0 then + return + end + + -- Loser of semifinals play in third place match + local firstBracketData = bracket.bracketDatasById[bracket.rootMatchIds[1]] + local thirdPlaceMatchId = firstBracketData.thirdPlaceMatchId + if thirdPlaceMatchId and bracket.matchesById[thirdPlaceMatchId] then + for _, lowerMatchId in ipairs(firstBracketData.lowerMatchIds) do + local bracketData = bracket.bracketDatasById[lowerMatchId] + bracketData.advanceSpots[2] = bracketData.advanceSpots[2] + or {bg = 'stayup', type = 'advance', matchId = thirdPlaceMatchId} + end + end + + -- Custom advance spots set via pbg params + for _, match in ipairs(bracket.matches) do + local pbgs = Array.mapIndexes(function(ix) + return Table.extract(match.extradata, 'pbg' .. ix) + end) + for i = 1, #pbgs do + match.bracketData.advanceSpots[i] = Table.merge( + match.bracketData.advanceSpots[i], + {bg = pbgs[i], type = 'custom'} + ) + end + end +end + +---Returns an array of all the IDs of root matches. The matches are sorted in display order. +---@param bracketDatasById table +---@return string[] +function BracketUtil.computeRootMatchIds(bracketDatasById) + -- Matches without upper matches + local rootMatchIds = {} + for matchId, bracketData in pairs(bracketDatasById) do + if not bracketData.upperMatchId + and not String.endsWith(matchId, 'RxMBR') then + table.insert(rootMatchIds, matchId) + end + end + + Array.sortInPlaceBy(rootMatchIds, function(matchId) + local coordinates = bracketDatasById[matchId].coordinates + return coordinates and {coordinates.rootIndex} or {-1, matchId} + end) + + return rootMatchIds +end + +---Populate bracketData.upperMatchId if it is missing. This can happen if the bracket template is missing data. +---@param bracketDatasById table +function BracketUtil.backfillUpperMatchIds(bracketDatasById) + local upperMatchIds = MatchGroupCoordinates.computeUpperMatchIds(bracketDatasById) + + for matchId, bracketData in pairs(bracketDatasById) do + bracketData.upperMatchId = upperMatchIds[matchId] + end +end + +---Populate bracketData.coordinates if it is missing. +---This can happen if the bracket template has not been recently purged. +---@param matchGroup MatchGroupUtilBracket +function BracketUtil.backfillCoordinates(matchGroup) + local bracketCoordinates = MatchGroupCoordinates.computeCoordinates(matchGroup) + + Table.mergeInto(matchGroup, bracketCoordinates) + for matchId, bracketData in pairs(matchGroup.bracketDatasById) do + bracketData.coordinates = bracketCoordinates.coordinatesByMatchId[matchId] + end +end + +---Convert 0-based indexes to 1-based +---@param record table +---@return table +function BracketUtil.indexTableFromRecord(record) + return Table.map(record, function(key, value) + if key:match('Index') and type(value) == 'number' then + return key, value + 1 + else + return key, value + end + end) +end + +---Convert 1-based indexes to 0-based +---@param coordinates table +---@return table +function BracketUtil.indexTableToRecord(coordinates) + return Table.map(coordinates, function(key, value) + if key:match('Index') and type(value) == 'number' then + return key, value - 1 + else + return key, value + end + end) +end + +---@param sectionIndex integer +---@param sectionCount integer +---@return string +function BracketUtil.sectionIndexToString(sectionIndex, sectionCount) + if sectionIndex == 1 then + return 'upper' + elseif sectionIndex == sectionCount then + return 'lower' + else + return 'mid' + end +end + + +---Converts R01-M003 to R1M3 +---@param matchId string +---@return string +function BracketUtil.matchIdToKey(matchId) + if matchId == 'RxMBR' or matchId == 'RxMTP' then + return matchId + end + local round, matchInRound = matchId:match('^R(%d+)%-M(%d+)$') + return 'R' .. tonumber(round) .. 'M' .. tonumber(matchInRound) +end + +---Converts R1M3 to R01-M003 +---@param matchKey string +---@return string +function BracketUtil.matchIdFromKey(matchKey) + if matchKey == 'RxMBR' or matchKey == 'RxMTP' then + return matchKey + end + local round, matchInRound = matchKey:match('^R(%d+)M(%d+)$') + if round and matchInRound then + -- Bracket format + return 'R' .. string.format('%02d', round) .. '-M' .. string.format('%03d', matchInRound) + else + -- Matchlist format + return string.format('%04d', matchKey) + end +end + +---Splits a matchId like h5HXaqbSVP_R02-M002 into the bracket ID h5HXaqbSVP and the base match ID R02-M002. +---@param matchId string +---@return string?, string? +function BracketUtil.splitMatchId(matchId) + return matchId:match('^(.-)_([%w-]+)$') +end + +return BracketUtil diff --git a/lua/wikis/commons/MatchGroup/Util/Match.lua b/lua/wikis/commons/MatchGroup/Util/Match.lua new file mode 100644 index 00000000000..ac1e1c64a37 --- /dev/null +++ b/lua/wikis/commons/MatchGroup/Util/Match.lua @@ -0,0 +1,257 @@ +--- +-- @Liquipedia +-- page=Module:MatchGroup/Util/Match +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local Date = Lua.import('Module:Date/Ext') +local Faction = Lua.import('Module:Faction') +local FnUtil = Lua.import('Module:FnUtil') +local Info = Lua.import('Module:Info', {loadData = true}) +local Json = Lua.import('Module:Json') +local Logic = Lua.import('Module:Logic') +local String = Lua.import('Module:StringUtils') +local Table = Lua.import('Module:Table') + +local BracketUtil = Lua.import('Module:MatchGroup/Util/Bracket') + +local NOW = os.time() + +local nilIfEmpty = String.nilIfEmpty + +--[[ +The match model: reading match records into matches, opponents, games and players, and the match ids that address them. + +Reading a match record does mean interpreting the bracket data it carries, +which is why this imports Module:MatchGroup/Util/Bracket. +]] +local MatchUtil = {} + +---Parse extradata as a JSON string if read from page variables. Otherwise create a copy if fetched from lpdb. +---The returned extradata table can then be mutated without altering the source. +---@param recordExtradata table|string? +---@return table +local function parseOrCopyExtradata(recordExtradata) + return type(recordExtradata) == 'string' and Json.parse(recordExtradata) + or type(recordExtradata) == 'table' and Table.copy(recordExtradata) + or {} +end + +---Converts a match record to a structurally typed table with the appropriate data types for field values. +---The match record is either a match created in the store bracket codepath (WikiSpecific.processMatch), +---or a record fetched from LPDB (MatchUtil.fetchMatchRecords). +---The returned match struct is used in various display components (Bracket, MatchSummary, etc) +--- +---This is the implementation used on wikis by default. Wikis may specify a different conversion by setting +---WikiSpecific.matchFromRecord. Refer to the starcraft2 wiki as an example. +---@param record match2 +---@return MatchGroupUtilMatch +function MatchUtil.matchFromRecord(record) + local extradata = parseOrCopyExtradata(record.extradata) + local opponents = Array.map(record.match2opponents, FnUtil.curry(MatchUtil.opponentFromRecord, record)) + local games = Array.map(record.match2games, function(game) return MatchUtil.gameFromRecord(game, #opponents) end) + local bracketData = BracketUtil.bracketDataFromRecord(Json.parseIfString(record.match2bracketdata)) + if bracketData.type == 'bracket' then + bracketData.lowerEdges = bracketData.lowerEdges + or BracketUtil.autoAssignLowerEdges(#bracketData.lowerMatchIds, #opponents) + end + + local walkover = nilIfEmpty(record.walkover) + + local match = { + bestof = tonumber(record.bestof) or 0, + bracketData = bracketData, + comment = nilIfEmpty(Table.extract(extradata, 'comment')), + extradata = extradata, + date = record.date, + dateIsExact = Logic.readBool(record.dateexact), + finished = Logic.readBool(record.finished), + game = record.game, + games = games, + icon = nilIfEmpty(record.icon), + iconDark = nilIfEmpty(record.icondark), + links = Json.parseIfString(record.links) or {}, + matchId = record.match2id, + liquipediatier = record.liquipediatier, + liquipediatiertype = record.liquipediatiertype, + mode = record.mode, + opponents = opponents, + pageName = record.pagename, + parent = record.parent, + patch = record.patch, + publisherTier = nilIfEmpty(record.publishertier), + resultType = nilIfEmpty(record.resulttype), + section = nilIfEmpty(record.section), + series = nilIfEmpty(record.series), + shortname = nilIfEmpty(record.shortname), + status = nilIfEmpty(record.status), + stream = Json.parseIfString(record.stream) or {}, + tickername = record.tickername, + timestamp = tonumber(Table.extract(extradata, 'timestamp')), + timezoneId = Table.extract(extradata, 'timezoneid'), + tournament = record.tournament, + type = nilIfEmpty(record.type) or 'literal', + vod = nilIfEmpty(record.vod), + walkover = walkover and walkover:lower() or nil, + winner = tonumber(record.winner), + } + + match.phase = MatchUtil.computeMatchPhase(match) + + return match +end + +---@param matchRecord match2 +---@param record match2opponent +---@param opponentIndex integer +---@return standardOpponent +function MatchUtil.opponentFromRecord(matchRecord, record, opponentIndex) + local extradata = parseOrCopyExtradata(record.extradata) + local score = record.score + local status = record.status + local bestof = tonumber(matchRecord.bestof) + local game1 = (matchRecord.match2games or {})[1] + local hasOnlyScores = Array.all(matchRecord.match2opponents, function(opponent) + return opponent.status == 'S' end) + local scoreDisplay = nil + if bestof == 1 and Info.config.match2.gameScoresIfBo1 and game1 and hasOnlyScores then + local mapOpponent = (game1.opponents or {})[opponentIndex] or {} + scoreDisplay = tonumber(mapOpponent.score) + status = mapOpponent.status + end + + return { + advanceBg = nilIfEmpty(Table.extract(extradata, 'bg')), + advances = Logic.readBoolOrNil(Table.extract(extradata, 'advances')), + extradata = extradata, + icon = nilIfEmpty(record.icon), + name = nilIfEmpty(record.name), + placement = tonumber(record.placement), + players = Array.map(record.match2players, MatchUtil.playerFromRecord), + score = tonumber(score), + scoreDisplay = scoreDisplay, + status = status, + template = nilIfEmpty(record.template), + type = nilIfEmpty(record.type) or 'literal', + } +end + +-- TODO: standardOpponent overlaps Module:Opponent without being convertible to it, and this is the +-- only constructor in the match model that does not read a record. Worth reconciling the two +-- opponent shapes, at which point this may not need to exist. +---@param args table +---@return standardOpponent +function MatchUtil.createOpponent(args) + return { + extradata = args.extradata or {}, + icon = args.icon, + name = args.name, + placement = args.placement, + players = args.players or {}, + score = args.score, + status = args.status, + template = args.template, + type = args.type or 'literal', + } +end + +---@param record table +---@return standardPlayer +function MatchUtil.playerFromRecord(record) + local extradata = parseOrCopyExtradata(record.extradata) + local faction = Faction.read(extradata.faction) + return { + displayName = record.displayname, + extradata = extradata, + flag = nilIfEmpty(record.flag), + pageName = record.name, + team = Table.extract(extradata, 'playerteam'), + faction = faction or Faction.defaultFaction, + pageIsResolved = Logic.isNotEmpty(faction), + } +end + +---@param record match2game +---@param opponentCount integer? +---@return MatchGroupUtilGame +function MatchUtil.gameFromRecord(record, opponentCount) + local extradata = parseOrCopyExtradata(record.extradata) + + return { + comment = nilIfEmpty(Table.extract(extradata, 'comment')), + date = record.date, + dateIsExact = nilIfEmpty(Table.extract(extradata, 'dateexact')), + extradata = extradata, + game = record.game, + header = nilIfEmpty(Table.extract(extradata, 'header')), + length = record.length, + map = nilIfEmpty(record.map), + mapDisplayName = nilIfEmpty(Table.extract(extradata, 'displayname')), + mode = nilIfEmpty(record.mode), + opponents = record.opponents, + patch = record.patch, + resultType = nilIfEmpty(record.resulttype), + status = nilIfEmpty(record.status), + scores = Json.parseIfString(record.scores) or {}, + subgroup = tonumber(record.subgroup), + timestamp = tonumber(Table.extract(extradata, 'timestamp')), + timezoneId = Table.extract(extradata, 'timezoneid'), + type = nilIfEmpty(record.type), + vod = nilIfEmpty(record.vod), + walkover = nilIfEmpty(record.walkover) and record.walkover:lower() or nil, + winner = tonumber(record.winner), + } +end + +---Group games on the subgroup field to form submatches +---@param match MatchGroupUtilMatch +---@return MatchGroupUtilSubgroup[] +function MatchUtil.groupBySubgroup(match) + local previousSubgroup = nil + ---@type MatchGroupUtilGame[]? + local currentGames = nil + ---@type MatchGroupUtilGame[][] + local submatchGames = {} + Array.forEach(match.games, function (game) + if previousSubgroup == nil or previousSubgroup ~= game.subgroup then + currentGames = {} + Array.appendWith(submatchGames, currentGames) + previousSubgroup = game.subgroup + end + ---@cast currentGames -nil + Array.appendWith(currentGames, game) + end) + return Array.map(submatchGames, function (games, groupIndex) + ---@type MatchGroupUtilSubgroup + return { + games = games, + subgroup = groupIndex, + header = Table.extract(match.extradata or {}, 'subgroup' .. groupIndex .. 'header'), + } + end) +end + + +---Determines the phase of a match based on its properties. +---@param match MatchGroupUtilMatch|MatchGroupUtilGame|PartialMatchGameRecord +---@return 'finished'|'ongoing'|'upcoming' +function MatchUtil.computeMatchPhase(match) + -- TODO: a dateIsExact of false becomes nil here, so it is treated as unknown rather than as + -- inexact, and a past match with dateIsExact = false reads as ongoing instead of upcoming. + -- Only the record spelling (dateexact = '0') currently makes a match inexact. + local isExact = Logic.readBoolOrNil(match.dateIsExact or match.dateexact) + local matchStartTimestamp = match.timestamp or Date.readTimestampOrNil(match.date) or Date.defaultTimestamp + if match.winner or Logic.readBool(match.finished) then + return 'finished' + elseif isExact ~= false and matchStartTimestamp ~= Date.defaultTimestamp and matchStartTimestamp <= NOW then + return 'ongoing' + else + return 'upcoming' + end +end + +return MatchUtil diff --git a/lua/wikis/commons/MatchGroup/Util/Starcraft.lua b/lua/wikis/commons/MatchGroup/Util/Starcraft.lua index 21a8eeb62ab..e3a48db58a4 100644 --- a/lua/wikis/commons/MatchGroup/Util/Starcraft.lua +++ b/lua/wikis/commons/MatchGroup/Util/Starcraft.lua @@ -258,10 +258,9 @@ end ---@param opponentIndex integer ---@return StarcraftStandardOpponent function StarcraftMatchGroupUtil.opponentFromRecord(matchRecord, record, opponentIndex) - local extradata = MatchGroupUtil.parseOrCopyExtradata(record.extradata) local opponent = MatchGroupUtil.opponentFromRecord(matchRecord, record, opponentIndex) --[[ @as StarcraftStandardOpponent]] - opponent.isArchon = Logic.readBool(extradata.isarchon) + opponent.isArchon = Logic.readBool(opponent.extradata.isarchon) return opponent end diff --git a/lua/wikis/commons/MatchGroup/Util/Types.lua b/lua/wikis/commons/MatchGroup/Util/Types.lua new file mode 100644 index 00000000000..d9b12571e53 --- /dev/null +++ b/lua/wikis/commons/MatchGroup/Util/Types.lua @@ -0,0 +1,372 @@ +--- +-- @Liquipedia +-- page=Module:MatchGroup/Util/Types +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local TypeUtil = Lua.import('Module:TypeUtil') + +--[[ +The shapes of the match group model: matches, opponents, games, players, bracket data and the +match groups that hold them. +]] +local Types = {} + +---@class MatchGroupUtilLowerEdge +---@field lowerMatchIndex number +---@field opponentIndex number + +Types.LowerEdge = TypeUtil.struct({ + lowerMatchIndex = 'number', + opponentIndex = 'number', +}) +---@alias AdvanceBg 'up'|'stayup'|'stay'|'staydown'|'down' +Types.AdvanceBg = TypeUtil.literalUnion('up', 'stayup', 'stay', 'staydown', 'down') +---@class MatchGroupUtilAdvanceSpot +---@field bg AdvanceBg +---@field matchId string? +---@field type string? + +Types.AdvanceSpot = TypeUtil.struct({ + bg = Types.AdvanceBg, + matchId = 'string?', + type = TypeUtil.literalUnion('advance', 'custom', 'qualify'), +}) + +---@class MatchGroupUtilBracketBracketData +---@field coordinates MatchGroupUtilMatchCoordinates +---@field advanceSpots MatchGroupUtilAdvanceSpot[] +---@field bracketResetMatchId string? +---@field bracketType string? +---@field header string? +---@field inheritedHeader string? +---@field lowerEdges MatchGroupUtilLowerEdge[]? +---@field lowerMatchIds string[] +---@field qualLose boolean? +---@field qualLoseLiteral string? +---@field qualSkip number? +---@field qualWin boolean? +---@field qualWinLiteral string? +---@field skipRound number? +---@field thirdPlaceMatchId string? +---@field title string? +---@field type 'bracket' +---@field upperMatchId string? +---@field matchId string? +---@field matchPage string? +---@field qualifiedHeader string? + +Types.BracketBracketData = TypeUtil.struct({ + advanceSpots = TypeUtil.array(Types.AdvanceSpot), + bracketResetMatchId = 'string?', + bracketType = 'string?', + header = 'string?', + inheritedHeader = 'string?', + lowerEdges = TypeUtil.array(Types.LowerEdge), + lowerMatchIds = TypeUtil.array('string'), + qualLose = 'boolean?', + qualLoseLiteral = 'string?', + qualSkip = 'number?', + qualWin = 'boolean?', + qualifiedHeader = 'string?', + qualWinLiteral = 'string?', + skipRound = 'number?', + thirdPlaceMatchId = 'string?', + title = 'string?', + type = TypeUtil.literal('bracket'), + upperMatchId = 'string?', +}) +---@class MatchGroupUtilMatchCoordinates +---@field depth number +---@field depthCount number +---@field matchIndexInRound number +---@field rootIndex number +---@field roundCount number +---@field roundIndex number +---@field sectionCount number +---@field sectionIndex number +---@field semanticDepth number +---@field semanticRoundIndex number + +Types.MatchCoordinates = TypeUtil.struct({ + depth = 'number', + depthCount = 'number', + matchIndexInRound = 'number', + rootIndex = 'number', + roundCount = 'number', + roundIndex = 'number', + sectionCount = 'number', + sectionIndex = 'number', + semanticDepth = 'number', + semanticRoundIndex = 'number', +}) +---@class MatchGroupUtilMatchlistBracketData +---@field header string? +---@field title string? +---@field dateHeader boolean? +---@field type 'matchlist' +---@field matchId string? +---@field matchPage string? + +Types.MatchlistBracketData = TypeUtil.struct({ + header = 'string?', + title = 'string?', + dateHeader = 'boolean?', + type = TypeUtil.literal('matchlist'), +}) +---@alias MatchGroupUtilBracketData MatchGroupUtilMatchlistBracketData|MatchGroupUtilBracketBracketData +Types.BracketData = TypeUtil.union( + Types.MatchlistBracketData, + Types.BracketBracketData +) + +---@class standardPlayer +---@field displayName string? +---@field flag string? +---@field pageName string? +---@field team string? +---@field extradata table? +---@field pageIsResolved boolean? +---@field faction string? +---@field apiId string? + +Types.Player = TypeUtil.struct({ + displayName = 'string?', + flag = 'string?', + pageName = 'string?', + team = 'string?', + extradata = 'table?', + pageIsResolved = 'boolean?', + faction = 'string?', +}) + +---@class standardOpponent +---@field advanceBg string? +---@field advances boolean? +---@field icon string? +---@field icondark string? +---@field name string? +---@field placement number? +---@field placement2 number? +---@field players standardPlayer[]? +---@field score number? +---@field scoreDisplay number? +---@field score2 number? +---@field status string? +---@field status2 string? +---@field template string? +---@field type OpponentType +---@field team string? +---@field extradata table + +Types.Opponent = TypeUtil.struct({ + advanceBg = 'string?', + advances = 'boolean?', + icon = 'string?', + name = 'string?', + placement = 'number?', + placement2 = 'number?', + players = TypeUtil.array(Types.Player), + score = 'number?', + score2 = 'number?', + status = 'string?', + status2 = 'string?', + template = 'string?', + type = 'string', + extradata = 'table', +}) + +---@class GameOpponent +---@field name string? +---@field players standardPlayer[] +---@field template string? +---@field type string + +Types.GameOpponent = TypeUtil.struct({ + name = 'string?', + players = TypeUtil.optional(TypeUtil.array(Types.Player)), + template = 'string?', + type = 'string', +}) + +---@alias MatchStatus 'notplayed'|''|nil +Types.Status = TypeUtil.optional(TypeUtil.literalUnion('notplayed', '')) + +---@class MatchGroupUtilGame +---@field comment string? +---@field date string? +---@field dateIsExact boolean +---@field game string? +---@field header string? +---@field length string|number? +---@field map string? +---@field mapDisplayName string? +---@field mode string? +---@field opponents {players: table[], score: number?, status: string?}[] +---@field patch string? +---@field resultType string? +---@field scores number[] +---@field subgroup number? +---@field type string? +---@field vod string? +---@field winner integer? +---@field status string? +---@field walkover string? +---@field extradata table? +---@field timestamp number +---@field timezoneId string? + +Types.Game = TypeUtil.struct({ + comment = 'string?', + date = 'string?', + game = 'string?', + header = 'string?', + length = TypeUtil.optional(TypeUtil.union('number', 'string')), + map = 'string?', + mapDisplayName = 'string?', + mode = 'string?', + patch = 'string?', + resultType = 'string?', + scores = TypeUtil.array('number'), + subgroup = 'number?', + type = 'string?', + vod = 'string?', + winner = 'number?', + extradata = 'table?', +}) + +---@class MatchGroupUtilMatch +---@field bracketData MatchGroupUtilBracketData +---@field comment string? +---@field date string +---@field dateIsExact boolean +---@field finished boolean +---@field game string? +---@field games MatchGroupUtilGame[] +---@field icon string? +---@field iconDark string? +---@field links table +---@field liquipediatier string? # TODO: camelCase +---@field liquipediatiertype string? # TODO: camelCase +---@field matchId string? +---@field mode string? +---@field opponents standardOpponent[] +---@field pageName string? +---@field parent string? +---@field patch string? +---@field phase 'upcoming'|'ongoing'|'finished' +---@field publisherTier string? +---@field resultType string? +---@field section string? +---@field series string? +---@field shortname string? +---@field status MatchStatus +---@field stream table +---@field tickername string? +---@field tournament string? +---@field type string? +---@field vod string? +---@field walkover string? +---@field winner number? +---@field extradata table? +---@field timestamp number +---@field timezoneId string? +---@field bestof number? + +Types.Match = TypeUtil.struct({ + bracketData = Types.BracketData, + comment = 'string?', + date = 'string', + dateIsExact = 'boolean', + finished = 'boolean', + game = 'string?', + games = TypeUtil.array(Types.Game), + icon = 'string?', + iconDark = 'string?', + links = 'table', + liquipediatier = 'string?', + liquipediatiertype = 'string?', + matchId = 'string?', + mode = 'string', + opponents = TypeUtil.array(Types.Opponent), + pageName = 'string?', + parent = 'string?', + patch = 'string?', + publisherTier = 'string?', + resultType = 'string?', + section = 'string?', + series = 'string?', + shortname = 'string?', + status = Types.Status, + stream = 'table', + tickername = 'string?', + tournament = 'string?', + type = 'string?', + vod = 'string?', + winner = 'number?', + extradata = 'table?', +}) + +---@class MatchGroupUtilSubgroup +---@field games MatchGroupUtilGame[] +---@field subgroup number +---@field header string? + +---@class FFAMatchGroupUtilMatch: MatchGroupUtilMatch +---@field games FFAMatchGroupUtilGame[] + +---@class FFAMatchGroupUtilGame: MatchGroupUtilGame +---@field stream table + +---@class MatchGroupUtilMatchlist +---@field bracketDatasById table +---@field matches MatchGroupUtilMatch[] +---@field matchesById table +---@field type 'matchlist' + +Types.Matchlist = TypeUtil.struct({ + bracketDatasById = TypeUtil.table('string', Types.BracketData), + matches = TypeUtil.array(Types.Match), + matchesById = TypeUtil.table('string', Types.Match), + type = TypeUtil.literal('matchlist'), +}) + +---@class MatchGroupUtilBracket +---@field bracketDatasById table +---@field coordinatesByMatchId table +---@field matches MatchGroupUtilMatch[] +---@field matchesById table +---@field rootMatchIds string[] +---@field rounds string[][] +---@field sections string[][] +---@field type 'bracket' + +Types.Bracket = TypeUtil.struct({ + bracketDatasById = TypeUtil.table('string', Types.BracketData), + coordinatesByMatchId = TypeUtil.table('string', Types.MatchCoordinates), + matches = TypeUtil.array(Types.Match), + matchesById = TypeUtil.table('string', Types.Match), + rootMatchIds = TypeUtil.array('string'), + rounds = TypeUtil.array(TypeUtil.array('string')), + sections = TypeUtil.array(TypeUtil.array('string')), + type = TypeUtil.literal('bracket'), +}) + +---@alias MatchGroupUtilMatchGroup MatchGroupUtilBracket|MatchGroupUtilMatchlist +Types.MatchGroup = TypeUtil.union( + Types.Matchlist, + Types.Bracket +) + +--- The subset of a match or game record that is enough to work out its phase. +---@class PartialMatchGameRecord +---@field date string +---@field dateexact boolean|string|nil # records carry '0'/'1' +---@field timestamp number? +---@field finished boolean? +---@field winner integer? + +return Types diff --git a/lua/wikis/dota2/MatchGroup/Input/Custom.lua b/lua/wikis/dota2/MatchGroup/Input/Custom.lua index a64093cb406..35159f97e14 100644 --- a/lua/wikis/dota2/MatchGroup/Input/Custom.lua +++ b/lua/wikis/dota2/MatchGroup/Input/Custom.lua @@ -17,7 +17,6 @@ local Table = Lua.import('Module:Table') local Variables = Lua.import('Module:Variables') local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') -local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local Opponent = Lua.import('Module:Opponent/Custom') @@ -56,7 +55,7 @@ function CustomMatchGroupInput.processMatch(match, options) if not options.isMatchPage then -- See if this match has a standalone match (match page), if so use the data from there - local standaloneMatchId = MatchGroupUtil.getStandaloneId(match.bracketid, match.matchid) + local standaloneMatchId = MatchGroupInputUtil.getStandaloneId(match.bracketid, match.matchid) local standaloneMatch = standaloneMatchId and MatchGroupInputUtil.fetchStandaloneMatch(standaloneMatchId) or nil if standaloneMatch then return MatchGroupInputUtil.mergeStandaloneIntoMatch(match, standaloneMatch) diff --git a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua index 392655af1f3..c56a6332ffc 100644 --- a/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua +++ b/lua/wikis/leagueoflegends/MatchGroup/Input/Custom.lua @@ -16,7 +16,6 @@ local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') -local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local CustomMatchGroupInput = {} @@ -51,7 +50,7 @@ function CustomMatchGroupInput.processMatch(match, options) if not options.isMatchPage then -- See if this match has a standalone match (match page), if so use the data from there - local standaloneMatchId = MatchGroupUtil.getStandaloneId(match.bracketid, match.matchid) + local standaloneMatchId = MatchGroupInputUtil.getStandaloneId(match.bracketid, match.matchid) local standaloneMatch = standaloneMatchId and MatchGroupInputUtil.fetchStandaloneMatch(standaloneMatchId) or nil if standaloneMatch then return MatchGroupInputUtil.mergeStandaloneIntoMatch(match, standaloneMatch) diff --git a/lua/wikis/valorant/MatchGroup/Input/Custom.lua b/lua/wikis/valorant/MatchGroup/Input/Custom.lua index d736ac473d8..17b3df64621 100644 --- a/lua/wikis/valorant/MatchGroup/Input/Custom.lua +++ b/lua/wikis/valorant/MatchGroup/Input/Custom.lua @@ -15,7 +15,6 @@ local Operator = Lua.import('Module:Operator') local Table = Lua.import('Module:Table') local MatchGroupInputUtil = Lua.import('Module:MatchGroup/Input/Util') -local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local CustomMatchGroupInput = {} @@ -79,7 +78,7 @@ function CustomMatchGroupInput.processMatch(match, options) if not options.isMatchPage then -- See if this match has a standalone match (match page), if so use the data from there - local standaloneMatchId = MatchGroupUtil.getStandaloneId(match.bracketid, match.matchid) + local standaloneMatchId = MatchGroupInputUtil.getStandaloneId(match.bracketid, match.matchid) local standaloneMatch = standaloneMatchId and MatchGroupInputUtil.fetchStandaloneMatch(standaloneMatchId) or nil if standaloneMatch then return MatchGroupInputUtil.mergeStandaloneIntoMatch(match, standaloneMatch) From c1b9341df6d16dcf626ebdb8845dd0fa00367f00 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Mon, 24 Aug 2026 13:18:30 +0200 Subject: [PATCH 2/5] refactor: bulletproof step 2 --- .github/workflows/luacheck.yml | 17 ++ ...acket_spec.lua => domain_bracket_spec.lua} | 81 ++++--- ...l_match_spec.lua => domain_match_spec.lua} | 109 ++++++--- lua/spec/domain_team_history_spec.lua | 228 ++++++++++++++++++ lua/spec/match_group_util_spec.lua | 6 +- lua/spec/squad_history_spec.lua | 195 +-------------- lua/wikis/ageofempires/MatchSummary/Ffa.lua | 2 +- lua/wikis/apexlegends/MatchSummary/Ffa.lua | 2 +- lua/wikis/arenafps/MatchSummary/Ffa.lua | 2 +- lua/wikis/autochess/MatchSummary/Ffa.lua | 2 +- lua/wikis/callofduty/MatchSummary/Ffa.lua | 2 +- lua/wikis/commons/Bracket/Template.lua | 2 +- .../Bracket}/Coordinates.lua | 2 +- .../Bracket.lua => Domain/Bracket/Model.lua} | 86 ++++++- .../Util/Match.lua => Domain/Match/Model.lua} | 157 +++++++++++- lua/wikis/commons/Domain/Match/Store.lua | 56 +++++ .../commons/Domain/TeamHistory/Model.lua | 205 ++++++++++++++++ .../TeamHistory/Store.lua} | 23 +- lua/wikis/commons/Features/Squad/Auto.lua | 6 +- .../commons/Features/Squad/Lib/History.lua | 155 +----------- lua/wikis/commons/Features/Squad/Types.lua | 30 --- lua/wikis/commons/GameSummary.lua | 2 +- lua/wikis/commons/GameSummary/Starcraft.lua | 2 +- .../commons/MatchGroup/Display/Bracket.lua | 6 +- .../MatchGroup/Display/Horizontallist.lua | 4 +- .../commons/MatchGroup/Display/SingleGame.lua | 2 +- .../MatchGroup/Display/SingleMatch.lua | 2 +- lua/wikis/commons/MatchGroup/Util.lua | 62 ++--- lua/wikis/commons/MatchGroup/Util/Types.lua | 192 +-------------- lua/wikis/commons/MatchSummary/Base.lua | 2 +- .../commons/MatchSummary/Starcraft/Ffa.lua | 2 +- lua/wikis/commons/PrizePool/Import.lua | 2 +- lua/wikis/counterstrike/MatchSummary/Ffa.lua | 2 +- lua/wikis/deltaforce/MatchSummary/Ffa.lua | 2 +- lua/wikis/fortnite/MatchSummary/Ffa.lua | 2 +- lua/wikis/freefire/MatchSummary/Ffa.lua | 2 +- lua/wikis/geoguessr/GameSummary.lua | 2 +- lua/wikis/geoguessr/MatchSummary/Ffa.lua | 2 +- lua/wikis/halo/MatchSummary/Ffa.lua | 2 +- lua/wikis/hearthstone/MatchSummary/Ffa.lua | 2 +- lua/wikis/lab/MatchSummary/Ffa.lua | 2 +- lua/wikis/naraka/MatchSummary/Ffa.lua | 2 +- lua/wikis/pubg/MatchSummary/Ffa.lua | 2 +- lua/wikis/pubgmobile/MatchSummary/Ffa.lua | 2 +- lua/wikis/tarkovarena/MatchSummary/Ffa.lua | 2 +- lua/wikis/tft/MatchSummary/Ffa.lua | 2 +- lua/wikis/thefinals/GameSummary.lua | 2 +- lua/wikis/thefinals/MatchSummary/Ffa.lua | 2 +- lua/wikis/trackmania/MatchSummary/Ffa.lua | 2 +- lua/wikis/underlords/MatchSummary/Ffa.lua | 2 +- lua/wikis/warcraft/GameSummary.lua | 2 +- lua/wikis/warcraft/MatchSummary/Ffa.lua | 2 +- scripts/check_lua_imports.py | 138 +++++++++++ 53 files changed, 1081 insertions(+), 743 deletions(-) rename lua/spec/{match_group_util_bracket_spec.lua => domain_bracket_spec.lua} (82%) rename lua/spec/{match_group_util_match_spec.lua => domain_match_spec.lua} (81%) create mode 100644 lua/spec/domain_team_history_spec.lua rename lua/wikis/commons/{MatchGroup => Domain/Bracket}/Coordinates.lua (99%) rename lua/wikis/commons/{MatchGroup/Util/Bracket.lua => Domain/Bracket/Model.lua} (78%) rename lua/wikis/commons/{MatchGroup/Util/Match.lua => Domain/Match/Model.lua} (65%) create mode 100644 lua/wikis/commons/Domain/Match/Store.lua create mode 100644 lua/wikis/commons/Domain/TeamHistory/Model.lua rename lua/wikis/commons/{Features/Squad/Api/TransferHistory.lua => Domain/TeamHistory/Store.lua} (82%) create mode 100644 scripts/check_lua_imports.py diff --git a/.github/workflows/luacheck.yml b/.github/workflows/luacheck.yml index 53070849621..6fed6bfea79 100644 --- a/.github/workflows/luacheck.yml +++ b/.github/workflows/luacheck.yml @@ -35,6 +35,23 @@ jobs: check_name: 'Lint Report' annotate_only: ${{ (github.event.pull_request.head.repo.full_name != github.repository) }} + lua-import-rules: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + # Fails on violations coming out of Features/ and Domain/, warns for code that has not + # been restructured yet. Those warnings become errors as each area is migrated. + - name: Check import rules + run: python scripts/check_lua_imports.py + lua-unit-test: runs-on: ubuntu-latest diff --git a/lua/spec/match_group_util_bracket_spec.lua b/lua/spec/domain_bracket_spec.lua similarity index 82% rename from lua/spec/match_group_util_bracket_spec.lua rename to lua/spec/domain_bracket_spec.lua index dfafb372a88..5172757e183 100644 --- a/lua/spec/match_group_util_bracket_spec.lua +++ b/lua/spec/domain_bracket_spec.lua @@ -4,22 +4,22 @@ ---@diagnostic disable: missing-fields --[[ -Tests for Module:MatchGroup/Util/Bracket, the bracket model. +Tests for Module:Domain/Bracket/Model, the bracket model. Bracket data records, topology, coordinates and the id namespace that addresses a match inside its match group. A matchlist counts as a bracket here, which is why the flat match id forms live with the bracket ones. ]] -insulate('MatchGroup/Util/Bracket', function() +insulate('Domain/Bracket/Model', function() describe('bracketDataFromRecord', function() it('returns an empty table when there is no data', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({}, BracketUtil.bracketDataFromRecord(nil)) end) it('reads matchlist bracket data', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketData = BracketUtil.bracketDataFromRecord{ type = 'matchlist', header = 'Round 1', @@ -40,12 +40,12 @@ insulate('MatchGroup/Util/Bracket', function() end) it('treats anything that is not a bracket as a matchlist', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_equal('matchlist', BracketUtil.bracketDataFromRecord{header = 'x'}.type) end) it('reads bracket bracket data', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketData = BracketUtil.bracketDataFromRecord{ type = 'bracket', header = 'Semifinals', @@ -70,7 +70,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('derives the qualification flags from the advance spots', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketData = BracketUtil.bracketDataFromRecord{ type = 'bracket', qualwin = 'true', @@ -84,7 +84,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('reads qualskip and skipround as numbers, including the true spelling', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local fromStrings = BracketUtil.bracketDataFromRecord{ type = 'bracket', qualskip = 'true', skipround = 'true', } @@ -99,7 +99,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('converts the coordinate indexes from zero based to one based', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketData = BracketUtil.bracketDataFromRecord{ type = 'bracket', coordinates = {roundIndex = 0, sectionIndex = 1, roundCount = 3, depth = 2}, @@ -115,7 +115,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('bracketDataToRecord', function() it('round trips a bracket bracket data', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local record = BracketUtil.bracketDataToRecord{ type = 'bracket', header = 'Semifinals', @@ -148,7 +148,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('computeLowerMatchIdsFromLegacy', function() it('reads toupper before tolower and skips empty ones', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({'R02-M001', 'R02-M002'}, BracketUtil.computeLowerMatchIdsFromLegacy{toupper = 'R02-M001', tolower = 'R02-M002'}) assert.are_same({'R02-M002'}, @@ -159,7 +159,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('autoAssignLowerEdges', function() it('centres the lower matches when there are fewer of them than opponents', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({{lowerMatchIndex = 1, opponentIndex = 2}}, BracketUtil.autoAssignLowerEdges(1, 3)) assert.are_same({ {lowerMatchIndex = 1, opponentIndex = 1}, @@ -168,7 +168,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('hangs the excess lower matches off the last opponent', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({ {lowerMatchIndex = 1, opponentIndex = 1}, {lowerMatchIndex = 2, opponentIndex = 2}, @@ -177,14 +177,14 @@ insulate('MatchGroup/Util/Bracket', function() end) it('returns nothing when there are no lower matches', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({}, BracketUtil.autoAssignLowerEdges(0, 2)) end) end) describe('computeAdvanceSpots', function() it('advances the winner to the upper match', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same( {{bg = 'up', type = 'advance', matchId = 'R02-M001'}}, BracketUtil.computeAdvanceSpots{upperMatchId = 'R02-M001'} @@ -192,7 +192,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('lets winnerto and loserto override the upper match', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local spots = BracketUtil.computeAdvanceSpots{ upperMatchId = 'R02-M001', winnerto = 'R02-M009', @@ -203,7 +203,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('marks qualification spots while keeping the target match', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local spots = BracketUtil.computeAdvanceSpots{ upperMatchId = 'R02-M001', qualwin = 'true', @@ -214,21 +214,21 @@ insulate('MatchGroup/Util/Bracket', function() end) it('returns nothing for a match that goes nowhere', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({}, BracketUtil.computeAdvanceSpots{}) end) end) describe('populateAdvanceSpots', function() it('does nothing for an empty bracket', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.is_true(pcall(function() BracketUtil.populateAdvanceSpots{matches = {}} end)) end) it('sends the semifinal losers to the third place match', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketDatasById = { ['R01-M001'] = {advanceSpots = {}}, ['R02-M001'] = {advanceSpots = {}, lowerMatchIds = {'R01-M001'}, thirdPlaceMatchId = 'RxMTP'}, @@ -246,7 +246,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('applies the pbg overrides from extradata', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketData = {advanceSpots = {}, lowerMatchIds = {}} BracketUtil.populateAdvanceSpots{ matches = {{extradata = {pbg1 = 'down', pbg2 = 'stay'}, bracketData = bracketData}}, @@ -260,9 +260,28 @@ insulate('MatchGroup/Util/Bracket', function() end) end) + describe('resetMatch', function() + it('finds the match that replays a grand final', function() + local BracketUtil = require('Module:Domain/Bracket/Model') + local reset = {matchId = 'R03-M001'} + assert.are_equal(reset, BracketUtil.resetMatch( + {['R03-M001'] = reset}, {bracketResetMatchId = 'R03-M001'})) + end) + + it('finds nothing for a match that has no reset', function() + local BracketUtil = require('Module:Domain/Bracket/Model') + assert.is_nil(BracketUtil.resetMatch({['R03-M001'] = {}}, {})) + end) + + it('finds nothing when the reset match is not in the bracket', function() + local BracketUtil = require('Module:Domain/Bracket/Model') + assert.is_nil(BracketUtil.resetMatch({}, {bracketResetMatchId = 'R03-M001'})) + end) + end) + describe('computeRootMatchIds', function() it('takes the matches that have no upper match, in coordinate order', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local rootMatchIds = BracketUtil.computeRootMatchIds{ ['R01-M001'] = {upperMatchId = 'R02-M001'}, ['R02-M001'] = {coordinates = {rootIndex = 2}}, @@ -272,7 +291,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('never treats a bracket reset match as a root', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_same({'R01-M001'}, BracketUtil.computeRootMatchIds{ ['R01-M001'] = {coordinates = {rootIndex = 1}}, ['abcdefghij_RxMBR'] = {coordinates = {rootIndex = 2}}, @@ -282,7 +301,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('backfillUpperMatchIds', function() it('points every lower match at the match above it', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketDatasById = { ['R01-M001'] = {lowerMatchIds = {}}, ['R01-M002'] = {lowerMatchIds = {}}, @@ -298,7 +317,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('index table conversion', function() it('shifts only the keys ending in Index', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local coordinates = {roundIndex = 0, sectionIndex = 2, roundCount = 4, matchId = 'R01-M001'} local oneBased = BracketUtil.indexTableFromRecord(coordinates) @@ -309,7 +328,7 @@ insulate('MatchGroup/Util/Bracket', function() describe('sectionIndexToString', function() it('names the first, last and middle sections', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_equal('upper', BracketUtil.sectionIndexToString(1, 3)) assert.are_equal('mid', BracketUtil.sectionIndexToString(2, 3)) assert.are_equal('lower', BracketUtil.sectionIndexToString(3, 3)) @@ -321,20 +340,20 @@ insulate('MatchGroup/Util/Bracket', function() -- match ids address a match inside its match group, so they live with the bracket half describe('match ids', function() it('splits a match id into bracket id and base match id', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') local bracketId, baseMatchId = BracketUtil.splitMatchId('h5HXaqbSVP_R02-M002') assert.are_equal('h5HXaqbSVP', bracketId) assert.are_equal('R02-M002', baseMatchId) end) it('returns nothing for a match id it cannot split', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.is_nil(BracketUtil.splitMatchId('R02-M002')) assert.is_nil((BracketUtil.splitMatchId(''))) end) it('converts between the padded and short match id forms', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_equal('R1M3', BracketUtil.matchIdToKey('R01-M003')) assert.are_equal('R12M345', BracketUtil.matchIdToKey('R12-M345')) assert.are_equal('R01-M003', BracketUtil.matchIdFromKey('R1M3')) @@ -342,7 +361,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('leaves the reset and third place placeholders alone', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_equal('RxMBR', BracketUtil.matchIdToKey('RxMBR')) assert.are_equal('RxMTP', BracketUtil.matchIdToKey('RxMTP')) assert.are_equal('RxMBR', BracketUtil.matchIdFromKey('RxMBR')) @@ -350,7 +369,7 @@ insulate('MatchGroup/Util/Bracket', function() end) it('pads a matchlist key to four digits', function() - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local BracketUtil = require('Module:Domain/Bracket/Model') assert.are_equal('0003', BracketUtil.matchIdFromKey('3')) end) diff --git a/lua/spec/match_group_util_match_spec.lua b/lua/spec/domain_match_spec.lua similarity index 81% rename from lua/spec/match_group_util_match_spec.lua rename to lua/spec/domain_match_spec.lua index 6f7c45874ad..e677379bdf9 100644 --- a/lua/spec/match_group_util_match_spec.lua +++ b/lua/spec/domain_match_spec.lua @@ -4,7 +4,7 @@ ---@diagnostic disable: missing-fields --[[ -Tests for Module:MatchGroup/Util/Match, the match model: reading match records into matches, +Tests for Module:Domain/Match/Model, the match model: reading match records into matches, opponents, games and players. ]] @@ -22,10 +22,10 @@ local function gameRecord(record) return record end -insulate('MatchGroup/Util/Match', function() +insulate('Domain/Match/Model', function() describe('matchFromRecord', function() it('reads a full bracket match record', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ match2id = 'abcdefghij_R01-M001', bestof = '3', @@ -60,7 +60,7 @@ insulate('MatchGroup/Util/Match', function() end) it('lifts comment, timestamp and timezone out of extradata', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -77,7 +77,7 @@ insulate('MatchGroup/Util/Match', function() end) it('fills in the defaults of a minimal record', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -98,7 +98,7 @@ insulate('MatchGroup/Util/Match', function() end) it('lower cases the walkover and blanks out empty strings', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -117,7 +117,7 @@ insulate('MatchGroup/Util/Match', function() end) it('parses json encoded links, stream and bracket data', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -134,7 +134,7 @@ insulate('MatchGroup/Util/Match', function() end) it('auto assigns lower edges for bracket matches that have none', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -159,7 +159,7 @@ insulate('MatchGroup/Util/Match', function() ---@param matchOverrides table? ---@return standardOpponent local function readOpponent(opponent, matchOverrides) - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local record = matchRecord(require('Module:Table').merge( {match2opponents = {opponent}, match2games = {}}, matchOverrides or {} @@ -212,7 +212,7 @@ insulate('MatchGroup/Util/Match', function() describe('createOpponent', function() it('fills in the defaults of a bare opponent', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_same({ extradata = {}, players = {}, @@ -221,7 +221,7 @@ insulate('MatchGroup/Util/Match', function() end) it('passes the given fields through', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local opponent = MatchUtil.createOpponent{name = 'A', score = 2, type = 'team', template = 'a'} assert.are_equal('A', opponent.name) assert.are_equal(2, opponent.score) @@ -232,7 +232,7 @@ insulate('MatchGroup/Util/Match', function() describe('playerFromRecord', function() it('reads a player and pulls the team out of extradata', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local player = MatchUtil.playerFromRecord{ name = 'Player_A', displayname = 'pA', @@ -248,14 +248,14 @@ insulate('MatchGroup/Util/Match', function() end) it('blanks out an empty flag', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.is_nil(MatchUtil.playerFromRecord{name = 'A', flag = ''}.flag) end) end) describe('gameFromRecord', function() it('reads a game and lifts its display fields out of extradata', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local game = MatchUtil.gameFromRecord(gameRecord{ map = 'Map1', winner = '1', @@ -290,14 +290,14 @@ insulate('MatchGroup/Util/Match', function() end) it('defaults the scores to an empty list', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_same({}, MatchUtil.gameFromRecord(gameRecord{map = 'Map1'}, 2).scores) end) end) describe('record extradata', function() it('parses json encoded extradata', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', @@ -310,7 +310,7 @@ insulate('MatchGroup/Util/Match', function() it('does not mutate the extradata of the record it was given', function() -- the model lifts comment/timestamp/timezoneid out of extradata, so it has to work on a copy - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local extradata = {comment = 'a comment', timestamp = 1641384000, keepme = 'yes'} local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', @@ -326,7 +326,7 @@ insulate('MatchGroup/Util/Match', function() end) it('defaults to an empty table when the record has none', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local match = MatchUtil.matchFromRecord(matchRecord{ date = '2022-01-05', mode = 'team', match2opponents = {}, match2games = {}, }) @@ -336,7 +336,7 @@ insulate('MatchGroup/Util/Match', function() describe('groupBySubgroup', function() it('groups consecutive games with the same subgroup and takes their headers', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local submatches = MatchUtil.groupBySubgroup{ extradata = {subgroup1header = 'First', subgroup2header = 'Second'}, games = { @@ -355,7 +355,7 @@ insulate('MatchGroup/Util/Match', function() end) it('numbers the submatches by position, not by the subgroup value', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local submatches = MatchUtil.groupBySubgroup{ extradata = {}, games = {{map = 'M1', subgroup = 5}, {map = 'M2', subgroup = 9}}, @@ -366,7 +366,7 @@ insulate('MatchGroup/Util/Match', function() end) it('starts a new submatch every time the subgroup changes back', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') local submatches = MatchUtil.groupBySubgroup{ extradata = {}, games = {{subgroup = 1}, {subgroup = 2}, {subgroup = 1}}, @@ -375,32 +375,32 @@ insulate('MatchGroup/Util/Match', function() end) it('returns nothing for a match without games', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_same({}, MatchUtil.groupBySubgroup{extradata = {}, games = {}}) end) end) describe('computeMatchPhase', function() it('is finished once there is a winner or the finished flag is set', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('finished', MatchUtil.computeMatchPhase{winner = 1, date = '2099-01-01'}) assert.are_equal('finished', MatchUtil.computeMatchPhase{finished = true, date = '2099-01-01'}) end) it('is ongoing once an exact start time has passed', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('ongoing', MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateIsExact = true}) end) it('is upcoming before the start time', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('upcoming', MatchUtil.computeMatchPhase{date = '2099-01-01 12:00:00', dateIsExact = true}) end) it('is upcoming when the date is explicitly not exact', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('upcoming', MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateexact = '0'}) end) @@ -408,18 +408,18 @@ insulate('MatchGroup/Util/Match', function() it('ignores a dateIsExact of false, because it is read with an or', function() -- `match.dateIsExact or match.dateexact` turns a false into a nil, which is not the same as -- an explicit false, so a past match with dateIsExact = false still counts as ongoing - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('ongoing', MatchUtil.computeMatchPhase{date = '2020-01-01 12:00:00', dateIsExact = false}) end) it('is upcoming when there is no usable date', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('upcoming', MatchUtil.computeMatchPhase{}) end) it('accepts a raw record as well as a model, via dateexact and timestamp', function() - local MatchUtil = require('Module:MatchGroup/Util/Match') + local MatchUtil = require('Module:Domain/Match/Model') assert.are_equal('ongoing', MatchUtil.computeMatchPhase{dateexact = '1', timestamp = 1577880000}) end) @@ -427,18 +427,15 @@ insulate('MatchGroup/Util/Match', function() end) --[[ -Reading map scores onto the opponent instead of the match score is per wiki configuration, -so it needs a wiki that turns it on. +Reading map scores onto the opponent instead of the match score is a per wiki rule, which the +model takes as a parameter. No wiki has to be stood up to test it. ]] -insulate('MatchGroup/Util/Match on a wiki with game scores in a best of one', function() - setup(function() SetActiveWiki('counterstrike') end) - teardown(function() SetActiveWiki() end) - +insulate('Domain/Match/Model with game scores in a best of one', function() ---@param record table ---@return standardOpponent local function readFirstOpponent(record) - local MatchUtil = require('Module:MatchGroup/Util/Match') - return MatchUtil.opponentFromRecord(record, record.match2opponents[1], 1) + local MatchUtil = require('Module:Domain/Match/Model') + return MatchUtil.opponentFromRecord(record, record.match2opponents[1], 1, {gameScoresIfBo1 = true}) end it('shows the map score of a best of one', function() @@ -481,3 +478,41 @@ insulate('MatchGroup/Util/Match on a wiki with game scores in a best of one', fu assert.is_nil(opponent.scoreDisplay) end) end) + +--[[ +The default rules come from Info.config, which is the path production takes, so this one does need +a wiki that turns the rule on. +]] +insulate('Domain/Match/Model reading the per wiki defaults', function() + setup(function() SetActiveWiki('counterstrike') end) + teardown(function() SetActiveWiki() end) + + ---@param options table? + ---@return standardOpponent + local function readFirstOpponent(options) + local MatchUtil = require('Module:Domain/Match/Model') + local record = matchRecord{ + bestof = '1', + match2opponents = { + {name = 'A', score = '1', status = 'S', match2players = {}}, + {name = 'B', score = '0', status = 'S', match2players = {}}, + }, + match2games = {{opponents = {{score = '16', status = 'S'}, {score = '14', status = 'S'}}}}, + } + return MatchUtil.opponentFromRecord(record, record.match2opponents[1], 1, options) + end + + it('applies the configured rule when the caller passes no options', function() + assert.are_equal(16, readFirstOpponent().scoreDisplay) + end) + + it('keeps the configured rule when the caller overrides nothing', function() + -- the options are merged under the defaults rather than replacing them, so a caller + -- overriding one rule does not silently lose the others + assert.are_equal(16, readFirstOpponent({}).scoreDisplay) + end) + + it('lets the caller turn the rule off', function() + assert.is_nil(readFirstOpponent({gameScoresIfBo1 = false}).scoreDisplay) + end) +end) diff --git a/lua/spec/domain_team_history_spec.lua b/lua/spec/domain_team_history_spec.lua new file mode 100644 index 00000000000..fd33d45beab --- /dev/null +++ b/lua/spec/domain_team_history_spec.lua @@ -0,0 +1,228 @@ +--- Triple Comment to Enable our LLS Plugin +--[[ +Tests for Module:Domain/TeamHistory/Model: reading transfer records into one team's history of a person. +]] + +local TeamHistory = require('Module:Domain/TeamHistory/Model') +local Table = require('Module:Table') + +local TransferType = TeamHistory.TransferType + +local TEAM = 'mouz 2021' +local OTHER_TEAM = 'team liquid 2024' +local TEAMS = {'mousesports orig', TEAM} + +---Builds a transfer record. `extradata` is merged rather than replaced. +---@param props table? +---@return table +local function transfer(props) + props = Table.copy(props or {}) + local extradata = Table.extract(props, 'extradata') or {} + local record = Table.merge({ + player = 'Alice', + nationality = 'se', + date = '2022-01-01', + fromteamtemplate = '', + toteamtemplate = '', + role1 = '', + role2 = '', + wholeteam = 0, + reference = {}, + }, props) + record.extradata = Table.merge({displayname = record.player}, extradata) + return record +end + +---Reads a transfer that is expected to be relevant to the team. +---@param record table +---@return TeamHistoryEntry +local function fromTransfer(record) + local result = TeamHistory.fromTransfer(record, TEAMS) + assert(result, 'expected the transfer to be relevant to the team') + return result +end + +describe('Team history', function() + + describe('fromTransfer', function() + it('reads a transfer into the team as a join', function() + local result = fromTransfer(transfer{ + toteamtemplate = TEAM, + role2 = 'Standin', + nationality = 'se', + extradata = {displayname = 'AliceTag', displaydate = '2022-01-??', position = 'Mid'}, + }) + + assert.are_equal(TransferType.JOIN, result.type) + assert.are_equal('Alice', result.pagename) + assert.are_equal('AliceTag', result.displayname) + assert.are_equal('se', result.flag) + assert.are_equal('2022-01-01', result.date) + assert.are_equal('2022-01-??', result.dateDisplay) + assert.are_equal('Mid', result.position) + assert.are_equal(TEAM, result.toTeam) + assert.are_equal('Standin', result.toRole) + assert.is_nil(result.fromTeam) + assert.is_nil(result.fromRole) + assert.is_false(result.wholeTeam) + end) + + it('reads a transfer away from the team as a leave', function() + local result = fromTransfer(transfer{ + fromteamtemplate = TEAM, + role1 = 'Standin', + toteamtemplate = OTHER_TEAM, + role2 = 'Coach', + }) + + assert.are_equal(TransferType.LEAVE, result.type) + assert.are_equal(TEAM, result.fromTeam) + assert.are_equal('Standin', result.fromRole) + -- the team the person went to is carried along so it can be shown as the next team + assert.are_equal(OTHER_TEAM, result.toTeam) + assert.are_equal('Coach', result.toRole) + end) + + it('reads a transfer within the team as a change', function() + local result = fromTransfer(transfer{ + fromteamtemplate = TEAM, + role1 = 'Standin', + toteamtemplate = TEAM, + role2 = 'Captain', + }) + + assert.are_equal(TransferType.CHANGE, result.type) + assert.are_equal('Standin', result.fromRole) + assert.are_equal('Captain', result.toRole) + end) + + it('matches any of the historical templates of the team', function() + local result = fromTransfer(transfer{toteamtemplate = 'mousesports orig'}) + assert.are_equal(TransferType.JOIN, result.type) + assert.are_equal('mousesports orig', result.toTeam) + end) + + it('follows the team through the secondary team fields', function() + local result = fromTransfer(transfer{ + toteamtemplate = OTHER_TEAM, + role2 = 'Coach', + extradata = {toteamsectemplate = TEAM, role2sec = 'Standin'}, + }) + + assert.are_equal(TransferType.JOIN, result.type) + assert.are_equal(TEAM, result.toTeam) + -- the role on the secondary team is the relevant one + assert.are_equal('Standin', result.toRole) + end) + + it('prefers the main team over the secondary one', function() + local result = fromTransfer(transfer{ + toteamtemplate = TEAM, + role2 = 'Captain', + extradata = {toteamsectemplate = 'mousesports orig', role2sec = 'Standin'}, + }) + assert.are_equal('Captain', result.toRole) + end) + + it('carries the next team from the secondary field when the person left it', function() + local result = fromTransfer(transfer{ + fromteamtemplate = OTHER_TEAM, + toteamtemplate = 'team liquid 2023', + role2 = 'Coach', + extradata = { + fromteamsectemplate = TEAM, + toteamsectemplate = 'team liquid 2023', + role1sec = 'Standin', + role2sec = 'Captain', + }, + }) + + assert.are_equal(TransferType.LEAVE, result.type) + assert.are_equal(TEAM, result.fromTeam) + assert.are_equal('Standin', result.fromRole) + -- the next team is taken from the secondary side too, not from the main one + assert.are_equal('team liquid 2023', result.toTeam) + assert.are_equal('Captain', result.toRole) + end) + + it('carries nothing along when the person left the team for nowhere', function() + local result = fromTransfer(transfer{ + fromteamtemplate = OTHER_TEAM, + toteamtemplate = '', + extradata = {fromteamsectemplate = TEAM, toteamsectemplate = '', role1sec = 'Standin'}, + }) + + assert.are_equal(TransferType.LEAVE, result.type) + assert.is_nil(result.toTeam) + assert.is_nil(result.toRole) + end) + + it('skips a transfer that changes nothing for the team', function() + -- the person moved between two other teams while keeping the same role here + assert.is_nil(TeamHistory.fromTransfer(transfer{ + fromteamtemplate = OTHER_TEAM, + toteamtemplate = 'team liquid 2023', + extradata = { + fromteamsectemplate = TEAM, + toteamsectemplate = TEAM, + role1sec = 'Standin', + role2sec = 'Standin', + }, + }, TEAMS)) + end) + + it('keeps a transfer that only changes the role within the team', function() + local result = fromTransfer(transfer{ + fromteamtemplate = TEAM, + role1 = 'Standin', + toteamtemplate = TEAM, + role2 = 'Captain', + }) + assert.is_truthy(result) + end) + + it('skips a transfer that does not involve the team at all', function() + assert.is_nil(TeamHistory.fromTransfer(transfer{ + fromteamtemplate = OTHER_TEAM, + toteamtemplate = OTHER_TEAM, + }, TEAMS)) + end) + + it('reads the whole team flag', function() + assert.is_true(fromTransfer(transfer{toteamtemplate = TEAM, wholeteam = 1}).wholeTeam) + end) + + it('copes with a record that has no extradata', function() + local record = transfer{toteamtemplate = TEAM} + record.extradata = nil + assert.are_equal(TransferType.JOIN, fromTransfer(record).type) + end) + end) + + describe('fromTransfers', function() + it('groups the entries per person', function() + local history = TeamHistory.fromTransfers({ + transfer{player = 'Alice', toteamtemplate = TEAM, date = '2021-01-01'}, + transfer{player = 'Bob', toteamtemplate = TEAM, date = '2021-02-01'}, + transfer{player = 'Alice', fromteamtemplate = TEAM, date = '2022-01-01'}, + }, TEAMS) + + assert.are_equal(2, #history.Alice) + assert.are_equal(1, #history.Bob) + assert.are_equal('2021-01-01', history.Alice[1].date) + assert.are_equal('2022-01-01', history.Alice[2].date) + end) + + it('leaves out a person whose every transfer was skipped', function() + local history = TeamHistory.fromTransfers({ + transfer{player = 'Alice', fromteamtemplate = OTHER_TEAM, toteamtemplate = OTHER_TEAM}, + }, TEAMS) + + assert.are_same({}, history) + end) + + it('returns an empty table when there are no transfers', function() + assert.are_same({}, TeamHistory.fromTransfers({}, TEAMS)) + end) + end) +end) diff --git a/lua/spec/match_group_util_spec.lua b/lua/spec/match_group_util_spec.lua index 4ad20b4b918..e03b8871ba8 100644 --- a/lua/spec/match_group_util_spec.lua +++ b/lua/spec/match_group_util_spec.lua @@ -170,7 +170,7 @@ insulate('MatchGroup/Util', function() record('resetbracket_RxMBR', {type = 'bracket'}), }) - local finals, resetMatch = MatchGroupUtil.fetchMatchForBracketDisplay( + local finals, resetMatch = MatchGroupUtil.fetchMatchWithBracketReset( 'resetbracket', 'resetbracket_R01-M001') assert.is_truthy(finals) @@ -194,8 +194,8 @@ insulate('MatchGroup/Util', function() describe('re-exports', function() it('points every match and bracket model member at its implementation', function() local MatchGroupUtil = require('Module:MatchGroup/Util') - local MatchUtil = require('Module:MatchGroup/Util/Match') - local BracketUtil = require('Module:MatchGroup/Util/Bracket') + local MatchUtil = require('Module:Domain/Match/Model') + local BracketUtil = require('Module:Domain/Bracket/Model') local Types = require('Module:MatchGroup/Util/Types') assert.are_equal(Types, MatchGroupUtil.types) diff --git a/lua/spec/squad_history_spec.lua b/lua/spec/squad_history_spec.lua index 975ec6bbe7e..c6d06b39c08 100644 --- a/lua/spec/squad_history_spec.lua +++ b/lua/spec/squad_history_spec.lua @@ -11,13 +11,13 @@ The end to end behaviour of the same logic stays covered by squad_auto_spec.lua. local SquadHistory = require('Module:Features/Squad/Lib/History') local SquadTypes = require('Module:Features/Squad/Types') +local TeamHistory = require('Module:Domain/TeamHistory/Model') local Table = require('Module:Table') -local TransferType = SquadTypes.TransferType +local TransferType = TeamHistory.TransferType local SquadStatus = SquadTypes.SquadStatus local TEAM = 'mouz 2021' -local OTHER_TEAM = 'team liquid 2024' local TEAMS = {'mousesports orig', TEAM} ---Builds a transfer record. `extradata` is merged rather than replaced. @@ -41,15 +41,6 @@ local function transfer(props) return record end ----Reads a transfer that is expected to be relevant to the team. ----@param record table ----@return TeamHistoryEntry -local function fromTransfer(record) - local result = SquadHistory.fromTransfer(record, TEAMS) - assert(result, 'expected the transfer to be relevant to the team') - return result -end - ---@param props table? ---@return TeamHistoryEntry local function entry(props) @@ -103,7 +94,7 @@ end ---@param ... table ---@return TeamHistoryEntry[] local function historyOf(...) - return SquadHistory.fromTransfers({...}, TEAMS).Alice or {} + return TeamHistory.fromTransfers({...}, TEAMS).Alice or {} end ---The selection a squad table of this status makes, where a row is expected. @@ -133,187 +124,7 @@ local function dateOf(historyEntry) end describe('Squad history', function() - describe('fromTransfer', function() - it('reads a transfer into the team as a join', function() - local result = fromTransfer(transfer{ - toteamtemplate = TEAM, - role2 = 'Standin', - nationality = 'se', - extradata = {displayname = 'AliceTag', displaydate = '2022-01-??', position = 'Mid'}, - }) - - assert.are_equal(TransferType.JOIN, result.type) - assert.are_equal('Alice', result.pagename) - assert.are_equal('AliceTag', result.displayname) - assert.are_equal('se', result.flag) - assert.are_equal('2022-01-01', result.date) - assert.are_equal('2022-01-??', result.dateDisplay) - assert.are_equal('Mid', result.position) - assert.are_equal(TEAM, result.toTeam) - assert.are_equal('Standin', result.toRole) - assert.is_nil(result.fromTeam) - assert.is_nil(result.fromRole) - assert.is_false(result.wholeTeam) - end) - - it('reads a transfer away from the team as a leave', function() - local result = fromTransfer(transfer{ - fromteamtemplate = TEAM, - role1 = 'Standin', - toteamtemplate = OTHER_TEAM, - role2 = 'Coach', - }) - - assert.are_equal(TransferType.LEAVE, result.type) - assert.are_equal(TEAM, result.fromTeam) - assert.are_equal('Standin', result.fromRole) - -- the team the person went to is carried along so it can be shown as the next team - assert.are_equal(OTHER_TEAM, result.toTeam) - assert.are_equal('Coach', result.toRole) - end) - - it('reads a transfer within the team as a change', function() - local result = fromTransfer(transfer{ - fromteamtemplate = TEAM, - role1 = 'Standin', - toteamtemplate = TEAM, - role2 = 'Captain', - }) - - assert.are_equal(TransferType.CHANGE, result.type) - assert.are_equal('Standin', result.fromRole) - assert.are_equal('Captain', result.toRole) - end) - - it('matches any of the historical templates of the team', function() - local result = fromTransfer(transfer{toteamtemplate = 'mousesports orig'}) - assert.are_equal(TransferType.JOIN, result.type) - assert.are_equal('mousesports orig', result.toTeam) - end) - - it('follows the team through the secondary team fields', function() - local result = fromTransfer(transfer{ - toteamtemplate = OTHER_TEAM, - role2 = 'Coach', - extradata = {toteamsectemplate = TEAM, role2sec = 'Standin'}, - }) - - assert.are_equal(TransferType.JOIN, result.type) - assert.are_equal(TEAM, result.toTeam) - -- the role on the secondary team is the relevant one - assert.are_equal('Standin', result.toRole) - end) - - it('prefers the main team over the secondary one', function() - local result = fromTransfer(transfer{ - toteamtemplate = TEAM, - role2 = 'Captain', - extradata = {toteamsectemplate = 'mousesports orig', role2sec = 'Standin'}, - }) - assert.are_equal('Captain', result.toRole) - end) - - it('carries the next team from the secondary field when the person left it', function() - local result = fromTransfer(transfer{ - fromteamtemplate = OTHER_TEAM, - toteamtemplate = 'team liquid 2023', - role2 = 'Coach', - extradata = { - fromteamsectemplate = TEAM, - toteamsectemplate = 'team liquid 2023', - role1sec = 'Standin', - role2sec = 'Captain', - }, - }) - - assert.are_equal(TransferType.LEAVE, result.type) - assert.are_equal(TEAM, result.fromTeam) - assert.are_equal('Standin', result.fromRole) - -- the next team is taken from the secondary side too, not from the main one - assert.are_equal('team liquid 2023', result.toTeam) - assert.are_equal('Captain', result.toRole) - end) - - it('carries nothing along when the person left the team for nowhere', function() - local result = fromTransfer(transfer{ - fromteamtemplate = OTHER_TEAM, - toteamtemplate = '', - extradata = {fromteamsectemplate = TEAM, toteamsectemplate = '', role1sec = 'Standin'}, - }) - - assert.are_equal(TransferType.LEAVE, result.type) - assert.is_nil(result.toTeam) - assert.is_nil(result.toRole) - end) - it('skips a transfer that changes nothing for the team', function() - -- the person moved between two other teams while keeping the same role here - assert.is_nil(SquadHistory.fromTransfer(transfer{ - fromteamtemplate = OTHER_TEAM, - toteamtemplate = 'team liquid 2023', - extradata = { - fromteamsectemplate = TEAM, - toteamsectemplate = TEAM, - role1sec = 'Standin', - role2sec = 'Standin', - }, - }, TEAMS)) - end) - - it('keeps a transfer that only changes the role within the team', function() - local result = fromTransfer(transfer{ - fromteamtemplate = TEAM, - role1 = 'Standin', - toteamtemplate = TEAM, - role2 = 'Captain', - }) - assert.is_truthy(result) - end) - - it('skips a transfer that does not involve the team at all', function() - assert.is_nil(SquadHistory.fromTransfer(transfer{ - fromteamtemplate = OTHER_TEAM, - toteamtemplate = OTHER_TEAM, - }, TEAMS)) - end) - - it('reads the whole team flag', function() - assert.is_true(fromTransfer(transfer{toteamtemplate = TEAM, wholeteam = 1}).wholeTeam) - end) - - it('copes with a record that has no extradata', function() - local record = transfer{toteamtemplate = TEAM} - record.extradata = nil - assert.are_equal(TransferType.JOIN, fromTransfer(record).type) - end) - end) - - describe('fromTransfers', function() - it('groups the entries per person', function() - local history = SquadHistory.fromTransfers({ - transfer{player = 'Alice', toteamtemplate = TEAM, date = '2021-01-01'}, - transfer{player = 'Bob', toteamtemplate = TEAM, date = '2021-02-01'}, - transfer{player = 'Alice', fromteamtemplate = TEAM, date = '2022-01-01'}, - }, TEAMS) - - assert.are_equal(2, #history.Alice) - assert.are_equal(1, #history.Bob) - assert.are_equal('2021-01-01', history.Alice[1].date) - assert.are_equal('2022-01-01', history.Alice[2].date) - end) - - it('leaves out a person whose every transfer was skipped', function() - local history = SquadHistory.fromTransfers({ - transfer{player = 'Alice', fromteamtemplate = OTHER_TEAM, toteamtemplate = OTHER_TEAM}, - }, TEAMS) - - assert.are_same({}, history) - end) - - it('returns an empty table when there are no transfers', function() - assert.are_same({}, SquadHistory.fromTransfers({}, TEAMS)) - end) - end) describe('an active squad', function() it('shows someone whose latest transfer put them on the team', function() diff --git a/lua/wikis/ageofempires/MatchSummary/Ffa.lua b/lua/wikis/ageofempires/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/ageofempires/MatchSummary/Ffa.lua +++ b/lua/wikis/ageofempires/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/apexlegends/MatchSummary/Ffa.lua b/lua/wikis/apexlegends/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/apexlegends/MatchSummary/Ffa.lua +++ b/lua/wikis/apexlegends/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/arenafps/MatchSummary/Ffa.lua b/lua/wikis/arenafps/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/arenafps/MatchSummary/Ffa.lua +++ b/lua/wikis/arenafps/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/autochess/MatchSummary/Ffa.lua b/lua/wikis/autochess/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/autochess/MatchSummary/Ffa.lua +++ b/lua/wikis/autochess/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/callofduty/MatchSummary/Ffa.lua b/lua/wikis/callofduty/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/callofduty/MatchSummary/Ffa.lua +++ b/lua/wikis/callofduty/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/commons/Bracket/Template.lua b/lua/wikis/commons/Bracket/Template.lua index a83e4374674..efa952e1073 100644 --- a/lua/wikis/commons/Bracket/Template.lua +++ b/lua/wikis/commons/Bracket/Template.lua @@ -14,7 +14,7 @@ local Template = Lua.import('Module:Template') local BracketDisplay = Lua.import('Module:MatchGroup/Display/Bracket') local Match = Lua.import('Module:Match') -local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') +local MatchGroupCoordinates = Lua.import('Module:Domain/Bracket/Coordinates') local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local Opponent = Lua.import('Module:Opponent') diff --git a/lua/wikis/commons/MatchGroup/Coordinates.lua b/lua/wikis/commons/Domain/Bracket/Coordinates.lua similarity index 99% rename from lua/wikis/commons/MatchGroup/Coordinates.lua rename to lua/wikis/commons/Domain/Bracket/Coordinates.lua index aabea647eb8..312da22861e 100644 --- a/lua/wikis/commons/MatchGroup/Coordinates.lua +++ b/lua/wikis/commons/Domain/Bracket/Coordinates.lua @@ -1,6 +1,6 @@ --- -- @Liquipedia --- page=Module:MatchGroup/Coordinates +-- page=Module:Domain/Bracket/Coordinates -- -- Please see https://github.com/Liquipedia/Lua-Modules to contribute -- diff --git a/lua/wikis/commons/MatchGroup/Util/Bracket.lua b/lua/wikis/commons/Domain/Bracket/Model.lua similarity index 78% rename from lua/wikis/commons/MatchGroup/Util/Bracket.lua rename to lua/wikis/commons/Domain/Bracket/Model.lua index 7127d3109db..c186f04927d 100644 --- a/lua/wikis/commons/MatchGroup/Util/Bracket.lua +++ b/lua/wikis/commons/Domain/Bracket/Model.lua @@ -1,6 +1,6 @@ --- -- @Liquipedia --- page=Module:MatchGroup/Util/Bracket +-- page=Module:Domain/Bracket/Model -- -- Please see https://github.com/Liquipedia/Lua-Modules to contribute -- @@ -12,7 +12,7 @@ local Logic = Lua.import('Module:Logic') local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') -local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') +local MatchGroupCoordinates = Lua.import('Module:Domain/Bracket/Coordinates') local nilIfEmpty = String.nilIfEmpty @@ -24,6 +24,80 @@ namespace that addresses a match within its group. A matchlist counts as a bracket here: it is the flat case of the same structure, which is why the matchlist id form lives alongside the bracket ones. ]] +---@class MatchGroupUtilLowerEdge +---@field lowerMatchIndex number +---@field opponentIndex number + +---@alias AdvanceBg 'up'|'stayup'|'stay'|'staydown'|'down' + +---@class MatchGroupUtilAdvanceSpot +---@field bg AdvanceBg +---@field matchId string? +---@field type string? + +---@class MatchGroupUtilBracketBracketData +---@field coordinates MatchGroupUtilMatchCoordinates +---@field advanceSpots MatchGroupUtilAdvanceSpot[] +---@field bracketResetMatchId string? +---@field bracketType string? +---@field header string? +---@field inheritedHeader string? +---@field lowerEdges MatchGroupUtilLowerEdge[]? +---@field lowerMatchIds string[] +---@field qualLose boolean? +---@field qualLoseLiteral string? +---@field qualSkip number? +---@field qualWin boolean? +---@field qualWinLiteral string? +---@field skipRound number? +---@field thirdPlaceMatchId string? +---@field title string? +---@field type 'bracket' +---@field upperMatchId string? +---@field matchId string? +---@field matchPage string? +---@field qualifiedHeader string? + +---@class MatchGroupUtilMatchCoordinates +---@field depth number +---@field depthCount number +---@field matchIndexInRound number +---@field rootIndex number +---@field roundCount number +---@field roundIndex number +---@field sectionCount number +---@field sectionIndex number +---@field semanticDepth number +---@field semanticRoundIndex number + +---@class MatchGroupUtilMatchlistBracketData +---@field header string? +---@field title string? +---@field dateHeader boolean? +---@field type 'matchlist' +---@field matchId string? +---@field matchPage string? + +---@alias MatchGroupUtilBracketData MatchGroupUtilMatchlistBracketData|MatchGroupUtilBracketBracketData + +---@class MatchGroupUtilMatchlist +---@field bracketDatasById table +---@field matches MatchGroupUtilMatch[] +---@field matchesById table +---@field type 'matchlist' + +---@class MatchGroupUtilBracket +---@field bracketDatasById table +---@field coordinatesByMatchId table +---@field matches MatchGroupUtilMatch[] +---@field matchesById table +---@field rootMatchIds string[] +---@field rounds string[][] +---@field sections string[][] +---@field type 'bracket' + +---@alias MatchGroupUtilMatchGroup MatchGroupUtilBracket|MatchGroupUtilMatchlist + local BracketUtil = {} ---@param data table? @@ -312,4 +386,12 @@ function BracketUtil.splitMatchId(matchId) return matchId:match('^(.-)_([%w-]+)$') end +---The match that replays this one when the lower bracket team wins the grand final. +---@param matchesById table +---@param bracketData MatchGroupUtilBracketData +---@return MatchGroupUtilMatch? +function BracketUtil.resetMatch(matchesById, bracketData) + return bracketData.bracketResetMatchId and matchesById[bracketData.bracketResetMatchId] or nil +end + return BracketUtil diff --git a/lua/wikis/commons/MatchGroup/Util/Match.lua b/lua/wikis/commons/Domain/Match/Model.lua similarity index 65% rename from lua/wikis/commons/MatchGroup/Util/Match.lua rename to lua/wikis/commons/Domain/Match/Model.lua index ac1e1c64a37..2a274225f02 100644 --- a/lua/wikis/commons/MatchGroup/Util/Match.lua +++ b/lua/wikis/commons/Domain/Match/Model.lua @@ -1,6 +1,6 @@ --- -- @Liquipedia --- page=Module:MatchGroup/Util/Match +-- page=Module:Domain/Match/Model -- -- Please see https://github.com/Liquipedia/Lua-Modules to contribute -- @@ -10,14 +10,13 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') local Date = Lua.import('Module:Date/Ext') local Faction = Lua.import('Module:Faction') -local FnUtil = Lua.import('Module:FnUtil') local Info = Lua.import('Module:Info', {loadData = true}) local Json = Lua.import('Module:Json') local Logic = Lua.import('Module:Logic') local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') -local BracketUtil = Lua.import('Module:MatchGroup/Util/Bracket') +local BracketUtil = Lua.import('Module:Domain/Bracket/Model') local NOW = os.time() @@ -26,11 +25,145 @@ local nilIfEmpty = String.nilIfEmpty --[[ The match model: reading match records into matches, opponents, games and players, and the match ids that address them. -Reading a match record does mean interpreting the bracket data it carries, -which is why this imports Module:MatchGroup/Util/Bracket. +Reading a match record does mean interpreting the bracket data it carries, which is why this +imports Module:Domain/Bracket/Model. + +Per wiki rules come from Info.config as defaults, so that any feature can read a match correctly +without going through another feature. Every such rule is overridable by a parameter, which is what +keeps this testable without standing a wiki up. ]] +---@class standardPlayer +---@field displayName string? +---@field flag string? +---@field pageName string? +---@field team string? +---@field extradata table? +---@field pageIsResolved boolean? +---@field faction string? +---@field apiId string? + +---@class standardOpponent +---@field advanceBg string? +---@field advances boolean? +---@field icon string? +---@field icondark string? +---@field name string? +---@field placement number? +---@field placement2 number? +---@field players standardPlayer[]? +---@field score number? +---@field scoreDisplay number? +---@field score2 number? +---@field status string? +---@field status2 string? +---@field template string? +---@field type OpponentType +---@field team string? +---@field extradata table + +---@class GameOpponent +---@field name string? +---@field players standardPlayer[] +---@field template string? +---@field type string + +---@alias MatchStatus 'notplayed'|''|nil + +---@class MatchGroupUtilGame +---@field comment string? +---@field date string? +---@field dateIsExact boolean +---@field game string? +---@field header string? +---@field length string|number? +---@field map string? +---@field mapDisplayName string? +---@field mode string? +---@field opponents {players: table[], score: number?, status: string?}[] +---@field patch string? +---@field resultType string? +---@field scores number[] +---@field subgroup number? +---@field type string? +---@field vod string? +---@field winner integer? +---@field status string? +---@field walkover string? +---@field extradata table? +---@field timestamp number +---@field timezoneId string? + +---@class MatchGroupUtilMatch +---@field bracketData MatchGroupUtilBracketData +---@field comment string? +---@field date string +---@field dateIsExact boolean +---@field finished boolean +---@field game string? +---@field games MatchGroupUtilGame[] +---@field icon string? +---@field iconDark string? +---@field links table +---@field liquipediatier string? # TODO: camelCase +---@field liquipediatiertype string? # TODO: camelCase +---@field matchId string? +---@field mode string? +---@field opponents standardOpponent[] +---@field pageName string? +---@field parent string? +---@field patch string? +---@field phase 'upcoming'|'ongoing'|'finished' +---@field publisherTier string? +---@field resultType string? +---@field section string? +---@field series string? +---@field shortname string? +---@field status MatchStatus +---@field stream table +---@field tickername string? +---@field tournament string? +---@field type string? +---@field vod string? +---@field walkover string? +---@field winner number? +---@field extradata table? +---@field timestamp number +---@field timezoneId string? +---@field bestof number? + +---@class MatchGroupUtilSubgroup +---@field games MatchGroupUtilGame[] +---@field subgroup number +---@field header string? + +---@class FFAMatchGroupUtilMatch: MatchGroupUtilMatch +---@field games FFAMatchGroupUtilGame[] + +---@class FFAMatchGroupUtilGame: MatchGroupUtilGame +---@field stream table + +--- The per wiki rules that reading a match record depends on. Passed in rather than read from +--- Info.config, so that the match model stays wiki agnostic and testable. +---@class MatchReadOptions +---@field gameScoresIfBo1 boolean? show the map score rather than the match score in a best of one + +--- The subset of a match or game record that is enough to work out its phase. +---@class PartialMatchGameRecord +---@field date string +---@field dateexact boolean|string|nil # records carry '0'/'1' +---@field timestamp number? +---@field finished boolean? +---@field winner integer? + local MatchUtil = {} +---The per wiki rules a match record is read under. Merged under whatever the caller passes, so +---overriding one rule still leaves the rest at their configured values. +---@return MatchReadOptions +local function defaultOptions() + return {gameScoresIfBo1 = Info.config.match2.gameScoresIfBo1} +end + ---Parse extradata as a JSON string if read from page variables. Otherwise create a copy if fetched from lpdb. ---The returned extradata table can then be mutated without altering the source. ---@param recordExtradata table|string? @@ -49,10 +182,14 @@ end ---This is the implementation used on wikis by default. Wikis may specify a different conversion by setting ---WikiSpecific.matchFromRecord. Refer to the starcraft2 wiki as an example. ---@param record match2 +---@param options MatchReadOptions? overrides the per wiki defaults ---@return MatchGroupUtilMatch -function MatchUtil.matchFromRecord(record) +function MatchUtil.matchFromRecord(record, options) local extradata = parseOrCopyExtradata(record.extradata) - local opponents = Array.map(record.match2opponents, FnUtil.curry(MatchUtil.opponentFromRecord, record)) + options = Table.merge(defaultOptions(), options) + local opponents = Array.map(record.match2opponents, function(opponentRecord, opponentIndex) + return MatchUtil.opponentFromRecord(record, opponentRecord, opponentIndex, options) + end) local games = Array.map(record.match2games, function(game) return MatchUtil.gameFromRecord(game, #opponents) end) local bracketData = BracketUtil.bracketDataFromRecord(Json.parseIfString(record.match2bracketdata)) if bracketData.type == 'bracket' then @@ -108,8 +245,10 @@ end ---@param matchRecord match2 ---@param record match2opponent ---@param opponentIndex integer +---@param options MatchReadOptions? ---@return standardOpponent -function MatchUtil.opponentFromRecord(matchRecord, record, opponentIndex) +function MatchUtil.opponentFromRecord(matchRecord, record, opponentIndex, options) + options = Table.merge(defaultOptions(), options) local extradata = parseOrCopyExtradata(record.extradata) local score = record.score local status = record.status @@ -118,7 +257,7 @@ function MatchUtil.opponentFromRecord(matchRecord, record, opponentIndex) local hasOnlyScores = Array.all(matchRecord.match2opponents, function(opponent) return opponent.status == 'S' end) local scoreDisplay = nil - if bestof == 1 and Info.config.match2.gameScoresIfBo1 and game1 and hasOnlyScores then + if bestof == 1 and options.gameScoresIfBo1 and game1 and hasOnlyScores then local mapOpponent = (game1.opponents or {})[opponentIndex] or {} scoreDisplay = tonumber(mapOpponent.score) status = mapOpponent.status diff --git a/lua/wikis/commons/Domain/Match/Store.lua b/lua/wikis/commons/Domain/Match/Store.lua new file mode 100644 index 00000000000..98448229b26 --- /dev/null +++ b/lua/wikis/commons/Domain/Match/Store.lua @@ -0,0 +1,56 @@ +--- +-- @Liquipedia +-- page=Module:Domain/Match/Store +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local Json = Lua.import('Module:Json') +local Operator = Lua.import('Module:Operator') +local Variables = Lua.import('Module:Variables') + +--[[ +Where match records come from. Every read of the match2 table belongs here, so that features can +ask the match domain for matches instead of writing their own queries. + +Returns raw records; turning them into matches is Module:Domain/Match/Model. +]] +local MatchStore = {} + +---Fetches all match ids of matches that satisfy the supplied condition +---@param props {conditions: string|AbstractConditionNode, limit: string|integer?, order: string?} +---@return string[] +function MatchStore.fetchMatchIds(props) + ---@type string[] + return Array.map(mw.ext.LiquipediaDB.lpdb('match2', { + limit = tonumber(props.limit) or 1000, + query = 'match2id', + conditions = tostring(props.conditions), + order = props.order + }), Operator.property('match2id')) +end + +---Fetches all matches in a matchlist or bracket. Tries to read from page variables before fetching from LPDB. +---Returns a list of records ordered lexicographically by matchId. +---@param bracketId string +---@return table[] +function MatchStore.fetchMatchRecords(bracketId) + local varData = Variables.varDefault('match2bracket_' .. bracketId) + if varData then + return (Json.parse(varData)) + end + + return mw.ext.LiquipediaDB.lpdb( + 'match2', + { + conditions = '([[namespace::0]] or [[namespace::>0]]) AND [[match2bracketid::' .. bracketId .. ']]', + order = 'match2id ASC', + limit = 5000, + } + ) +end + +return MatchStore diff --git a/lua/wikis/commons/Domain/TeamHistory/Model.lua b/lua/wikis/commons/Domain/TeamHistory/Model.lua new file mode 100644 index 00000000000..76b36f5c632 --- /dev/null +++ b/lua/wikis/commons/Domain/TeamHistory/Model.lua @@ -0,0 +1,205 @@ +--- +-- @Liquipedia +-- page=Module:Domain/TeamHistory/Model +-- +-- Please see https://github.com/Liquipedia/Lua-Modules to contribute +-- + +local Lua = require('Module:Lua') + +local Array = Lua.import('Module:Array') +local FnUtil = Lua.import('Module:FnUtil') +local Logic = Lua.import('Module:Logic') +local Operator = Lua.import('Module:Operator') + +--[[ +Answers "was this person on the team, and when" by reading transfer records from the point of view +of one team. + +Pure: it neither queries nor renders anything, and it knows nothing about how any feature displays +the result. +]] +local TeamHistory = {} + +---@enum TransferType +TeamHistory.TransferType = { + LEAVE = 'LEAVE', + JOIN = 'JOIN', + CHANGE = 'CHANGE', +} + +---@enum TransferSide +TeamHistory.TransferSide = { + FROM = 'from', + TO = 'to', +} + +--- One transfer, seen from the point of view of one team. +---@class (exact) TeamHistoryEntry +---@field pagename string +---@field displayname string +---@field flag string +---@field date string +---@field dateDisplay string? +---@field type TransferType +---@field references table +---@field wholeTeam boolean +---@field position string? +---@field fromTeam string? +---@field fromRole string? +---@field toTeam string? +---@field toRole string? +---@field faction string? + +local TransferType = TeamHistory.TransferType +local Side = TeamHistory.TransferSide + +---Checks whether a given team is one of the teams the history is built for +---@param teams string[] +---@param team string? +---@return boolean +local function isCurrentTeam(teams, team) + if not team then + return false + end + return Array.any(teams, FnUtil.curry(Operator.eq, team)) +end + +---Finds the team on one side of a transfer, if it is one of the teams the history is built for +---@param side TransferSide +---@param transfer transfer +---@param teams string[] +---@return string? team +---@return boolean isMain whether the team was found in the main rather than the secondary field +local function parseRelevantTeam(side, transfer, teams) + local mainTeam = transfer[side .. 'teamtemplate'] + if mainTeam and isCurrentTeam(teams, mainTeam) then + return mainTeam, true + end + + local secondaryTeam = transfer.extradata[side .. 'teamsectemplate'] + if secondaryTeam and isCurrentTeam(teams, secondaryTeam) then + return secondaryTeam, false + end + + return nil, false +end + +---Maps a transfer to a transfertype, with regards to the current team. +---@param relevantFromTeam string? +---@param relevantToTeam string? +---@return TransferType +local function getTransferType(relevantFromTeam, relevantToTeam) + if relevantFromTeam then + if relevantToTeam then + return TransferType.CHANGE + end + return TransferType.LEAVE + end + return TransferType.JOIN +end + +---Parses the relevant role for the current team from a transfer +---@param side TransferSide +---@param transfer transfer +---@param team string? +---@param isMain boolean +---@return string? +local function parseRelevantRole(side, transfer, team, isMain) + if not team then + return nil + end + + if isMain then + return side == Side.FROM and transfer.role1 or transfer.role2 + else + return side == Side.FROM and transfer.extradata.role1sec or transfer.extradata.role2sec + end +end + +---Reads a single transfer record from the point of view of a team. +---Returns nothing when the transfer does not change anything for that team. +---@param record transfer +---@param teams string[] every team template that counts as the team +---@return TeamHistoryEntry? +function TeamHistory.fromTransfer(record, teams) + record.extradata = record.extradata or {} + + local relevantFromTeam, isFromMain = parseRelevantTeam(Side.FROM, record, teams) + local relevantToTeam, isToMain = parseRelevantTeam(Side.TO, record, teams) + local transferType = getTransferType(relevantFromTeam, relevantToTeam) + + local fromRole = parseRelevantRole(Side.FROM, record, relevantFromTeam, isFromMain) + local toRole = parseRelevantRole(Side.TO, record, relevantToTeam, isToMain) + + -- For leave transfers: Pass on new team for display as next team + if transferType == TransferType.LEAVE and Logic.isEmpty(relevantToTeam) then + if isFromMain then + relevantToTeam = Logic.nilIfEmpty(record.toteamtemplate) + toRole = Logic.nilIfEmpty(record.role2) + else + relevantToTeam = Logic.nilIfEmpty(record.extradata.toteamsectemplate) + toRole = Logic.nilIfEmpty(record.extradata.role2sec) + end + end + + ---@type TeamHistoryEntry + local entry = { + type = transferType, + + -- Person related information + pagename = record.player, + displayname = record.extradata.displayname, + flag = record.nationality, + + -- Date and references + date = record.date, + dateDisplay = record.extradata.displaydate, + references = record.reference, + + -- Roles + fromRole = fromRole, + toRole = toRole, + + fromTeam = relevantFromTeam, + toTeam = relevantToTeam, + + -- Other + wholeTeam = Logic.readBool(record.wholeteam), + position = record.extradata.position, + faction = record.extradata.faction + } + + -- Skip this transfer if there is no relevant change, i.e. the role in this team didn't change + -- E.g. this is grabbed by secondary team, but only main team changed + if relevantFromTeam == relevantToTeam + and entry.fromRole == entry.toRole then + return nil + end + + return entry +end + +---Groups transfer records into a team history per person. +---A person whose every transfer is irrelevant to the team never enters the history at all. +---@param records transfer[] ordered by date ascending +---@param teams string[] every team template that counts as the team +---@return table +function TeamHistory.fromTransfers(records, teams) + ---@type table + local playersTeamHistory = {} + + Array.forEach(records, function(record) + local entry = TeamHistory.fromTransfer(record, teams) + if not entry then + return + end + + playersTeamHistory[record.player] = playersTeamHistory[record.player] or {} + table.insert(playersTeamHistory[record.player], entry) + end) + + return playersTeamHistory +end + +return TeamHistory diff --git a/lua/wikis/commons/Features/Squad/Api/TransferHistory.lua b/lua/wikis/commons/Domain/TeamHistory/Store.lua similarity index 82% rename from lua/wikis/commons/Features/Squad/Api/TransferHistory.lua rename to lua/wikis/commons/Domain/TeamHistory/Store.lua index ddc32e5731f..b832ca012f8 100644 --- a/lua/wikis/commons/Features/Squad/Api/TransferHistory.lua +++ b/lua/wikis/commons/Domain/TeamHistory/Store.lua @@ -1,6 +1,6 @@ --- -- @Liquipedia --- page=Module:Features/Squad/Api/TransferHistory +-- page=Module:Domain/TeamHistory/Store -- -- Please see https://github.com/Liquipedia/Lua-Modules to contribute -- @@ -13,7 +13,7 @@ local Json = Lua.import('Module:Json') local Lpdb = Lua.import('Module:Lpdb') local PageVariableNamespace = Lua.import('Module:PageVariableNamespace') -local SquadHistory = Lua.import('Module:Features/Squad/Lib/History') +local TeamHistory = Lua.import('Module:Domain/TeamHistory/Model') local BooleanOperator = Condition.BooleanOperator local Comparator = Condition.Comparator @@ -23,15 +23,18 @@ local pageVars = PageVariableNamespace() local QUERY_LIMIT = 5000 local QUERY_ORDER = 'date asc, objectname desc' ---- Every transfer read the squad tables do. -local SquadTransferHistory = {} +--[[ +Where a team's transfer history comes from. Every read of the transfer table for a team belongs +here, so that any feature can ask for a team history instead of writing its own query. +]] +local TeamHistoryStore = {} ---Builds the conditions to fetch all transfers related ---to the given team, respecting historical templates. ---@private ---@param teams string[] ---@return string -function SquadTransferHistory._buildConditions(teams) +function TeamHistoryStore._buildConditions(teams) local conditions = Condition.Tree(BooleanOperator.any) Array.forEach(teams, function (templatename) conditions:add{ @@ -50,7 +53,7 @@ end ---@param team string the team the squad table is for, used as the cache key ---@param teams string[] every team template that counts as that team ---@return table -function SquadTransferHistory.forTeam(team, teams) +function TeamHistoryStore.forTeam(team, teams) local teamHistoryKey = team .. '_all_transfers' ---@type table? @@ -63,7 +66,7 @@ function SquadTransferHistory.forTeam(team, teams) Lpdb.executeMassQuery( 'transfer', { - conditions = SquadTransferHistory._buildConditions(teams), + conditions = TeamHistoryStore._buildConditions(teams), order = QUERY_ORDER, limit = QUERY_LIMIT }, @@ -72,7 +75,7 @@ function SquadTransferHistory.forTeam(team, teams) end ) - local playersTeamHistory = SquadHistory.fromTransfers(records, teams) + local playersTeamHistory = TeamHistory.fromTransfers(records, teams) pageVars:set(teamHistoryKey, Json.stringify(playersTeamHistory)) return playersTeamHistory @@ -84,7 +87,7 @@ end ---@return string? newTeam ---@return string? newRole ---@return string? newDate -function SquadTransferHistory.fetchNextTeam(pagename, date) +function TeamHistoryStore.fetchNextTeam(pagename, date) local conditions = Condition.Tree(BooleanOperator.all) :add{ Condition.Util.anyOf(Condition.ColumnName('player'), {pagename, (string.gsub(pagename, ' ', '_'))}), @@ -102,4 +105,4 @@ function SquadTransferHistory.fetchNextTeam(pagename, date) return transfer.toteamtemplate, transfer.role2, transfer.date end -return SquadTransferHistory +return TeamHistoryStore diff --git a/lua/wikis/commons/Features/Squad/Auto.lua b/lua/wikis/commons/Features/Squad/Auto.lua index fb4ff6dbecd..6d47ff9bbd8 100644 --- a/lua/wikis/commons/Features/Squad/Auto.lua +++ b/lua/wikis/commons/Features/Squad/Auto.lua @@ -24,7 +24,7 @@ local TransferRefs = Lua.import('Module:Transfer/References') local SquadTypes = Lua.import('Module:Features/Squad/Types') local SquadHistory = Lua.import('Module:Features/Squad/Lib/History') -local SquadTransferHistory = Lua.import('Module:Features/Squad/Api/TransferHistory') +local TeamHistoryStore = Lua.import('Module:Domain/TeamHistory/Store') local SquadCustom = Lua.import('Module:Features/Squad/Custom') local INVALID_HISTORY_CATEGORY = 'SquadAuto with invalid player history' @@ -78,7 +78,7 @@ end ---Handles all necessary steps to fetch and sort data function SquadAuto:build() self:_parseConfig() - self.playersTeamHistory = SquadTransferHistory.forTeam(self.config.team, self.config.teams) + self.playersTeamHistory = TeamHistoryStore.forTeam(self.config.team, self.config.teams) local entries = self:_selectEntries() Array.forEach(entries, FnUtil.curry(SquadAuto._enrichEntry, self)) return entries @@ -402,7 +402,7 @@ function SquadAuto:_mapToSquadPerson(stint) -- On leave: Fetch the next team a person joined if Logic.isNotEmpty(leaveEntry) and Logic.isEmpty(entry.newteam) then - local newTeam, newRole, newDate = SquadTransferHistory.fetchNextTeam(joinEntry.pagename, leaveEntry.date) + local newTeam, newRole, newDate = TeamHistoryStore.fetchNextTeam(joinEntry.pagename, leaveEntry.date) if newTeam then entry.newteam = newTeam entry.newteamrole = newRole diff --git a/lua/wikis/commons/Features/Squad/Lib/History.lua b/lua/wikis/commons/Features/Squad/Lib/History.lua index 1ea60625987..c822a9c7798 100644 --- a/lua/wikis/commons/Features/Squad/Lib/History.lua +++ b/lua/wikis/commons/Features/Squad/Lib/History.lua @@ -8,168 +8,17 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') -local FnUtil = Lua.import('Module:FnUtil') -local Logic = Lua.import('Module:Logic') -local Operator = Lua.import('Module:Operator') local SquadTypes = Lua.import('Module:Features/Squad/Types') +local TeamHistory = Lua.import('Module:Domain/TeamHistory/Model') -local TransferType = SquadTypes.TransferType -local Side = SquadTypes.TransferSide +local TransferType = TeamHistory.TransferType local ROLE_INACTIVE = SquadTypes.ROLE_INACTIVE --- Answers "was this person on the team, and when" from transfer records. --- Pure: it neither queries nor renders anything, it only reads records and returns history. local SquadHistory = {} ----Checks whether a given team is one of the teams the history is built for ----@param teams string[] ----@param team string? ----@return boolean -local function isCurrentTeam(teams, team) - if not team then - return false - end - return Array.any(teams, FnUtil.curry(Operator.eq, team)) -end - ----Finds the team on one side of a transfer, if it is one of the teams the history is built for ----@param side TransferSide ----@param transfer transfer ----@param teams string[] ----@return string? team ----@return boolean isMain whether the team was found in the main rather than the secondary field -local function parseRelevantTeam(side, transfer, teams) - local mainTeam = transfer[side .. 'teamtemplate'] - if mainTeam and isCurrentTeam(teams, mainTeam) then - return mainTeam, true - end - - local secondaryTeam = transfer.extradata[side .. 'teamsectemplate'] - if secondaryTeam and isCurrentTeam(teams, secondaryTeam) then - return secondaryTeam, false - end - - return nil, false -end - ----Maps a transfer to a transfertype, with regards to the current team. ----@param relevantFromTeam string? ----@param relevantToTeam string? ----@return TransferType -local function getTransferType(relevantFromTeam, relevantToTeam) - if relevantFromTeam then - if relevantToTeam then - return TransferType.CHANGE - end - return TransferType.LEAVE - end - return TransferType.JOIN -end - ----Parses the relevant role for the current team from a transfer ----@param side TransferSide ----@param transfer transfer ----@param team string? ----@param isMain boolean ----@return string? -local function parseRelevantRole(side, transfer, team, isMain) - if not team then - return nil - end - - if isMain then - return side == Side.FROM and transfer.role1 or transfer.role2 - else - return side == Side.FROM and transfer.extradata.role1sec or transfer.extradata.role2sec - end -end - ----Reads a single transfer record from the point of view of a team. ----Returns nothing when the transfer does not change anything for that team. ----@param record transfer ----@param teams string[] every team template that counts as the team ----@return TeamHistoryEntry? -function SquadHistory.fromTransfer(record, teams) - record.extradata = record.extradata or {} - - local relevantFromTeam, isFromMain = parseRelevantTeam(Side.FROM, record, teams) - local relevantToTeam, isToMain = parseRelevantTeam(Side.TO, record, teams) - local transferType = getTransferType(relevantFromTeam, relevantToTeam) - - local fromRole = parseRelevantRole(Side.FROM, record, relevantFromTeam, isFromMain) - local toRole = parseRelevantRole(Side.TO, record, relevantToTeam, isToMain) - - -- For leave transfers: Pass on new team for display as next team - if transferType == TransferType.LEAVE and Logic.isEmpty(relevantToTeam) then - if isFromMain then - relevantToTeam = Logic.nilIfEmpty(record.toteamtemplate) - toRole = Logic.nilIfEmpty(record.role2) - else - relevantToTeam = Logic.nilIfEmpty(record.extradata.toteamsectemplate) - toRole = Logic.nilIfEmpty(record.extradata.role2sec) - end - end - - ---@type TeamHistoryEntry - local entry = { - type = transferType, - - -- Person related information - pagename = record.player, - displayname = record.extradata.displayname, - flag = record.nationality, - - -- Date and references - date = record.date, - dateDisplay = record.extradata.displaydate, - references = record.reference, - - -- Roles - fromRole = fromRole, - toRole = toRole, - - fromTeam = relevantFromTeam, - toTeam = relevantToTeam, - - -- Other - wholeTeam = Logic.readBool(record.wholeteam), - position = record.extradata.position, - faction = record.extradata.faction - } - - -- Skip this transfer if there is no relevant change, i.e. the role in this team didn't change - -- E.g. this is grabbed by secondary team, but only main team changed - if relevantFromTeam == relevantToTeam - and entry.fromRole == entry.toRole then - return nil - end - - return entry -end - ----Groups transfer records into a team history per person. ----A person whose every transfer is irrelevant to the team never enters the history at all. ----@param records transfer[] ordered by date ascending ----@param teams string[] every team template that counts as the team ----@return table -function SquadHistory.fromTransfers(records, teams) - ---@type table - local playersTeamHistory = {} - - Array.forEach(records, function(record) - local entry = SquadHistory.fromTransfer(record, teams) - if not entry then - return - end - - playersTeamHistory[record.player] = playersTeamHistory[record.player] or {} - table.insert(playersTeamHistory[record.player], entry) - end) - - return playersTeamHistory -end - ---Walks a person's team history as join -> [inactive] -> leave and returns the stints it found. ---@private ---@param entries TeamHistoryEntry[] diff --git a/lua/wikis/commons/Features/Squad/Types.lua b/lua/wikis/commons/Features/Squad/Types.lua index 084449c2522..33c308f6952 100644 --- a/lua/wikis/commons/Features/Squad/Types.lua +++ b/lua/wikis/commons/Features/Squad/Types.lua @@ -36,23 +36,6 @@ ---@field leavedateref table? ---@field inactivedateref table? ---- One transfer, seen from the point of view of one team. ----@class (exact) TeamHistoryEntry ----@field pagename string ----@field displayname string ----@field flag string ----@field date string ----@field dateDisplay string? ----@field type TransferType ----@field references table ----@field wholeTeam boolean ----@field position string? ----@field fromTeam string? ----@field fromRole string? ----@field toTeam string? ----@field toRole string? ----@field faction string? - --- The transfers behind a single squad row: what put a person on the team, what took them off the --- active squad, and what ended their time there. A person can have several of these on one team, --- and `inactiveEntry` is the transfer that made them inactive, not the last one before leaving. @@ -73,19 +56,6 @@ local SquadTypes = {} ----@enum TransferType -SquadTypes.TransferType = { - LEAVE = 'LEAVE', - JOIN = 'JOIN', - CHANGE = 'CHANGE', -} - ----@enum TransferSide -SquadTypes.TransferSide = { - FROM = 'from', - TO = 'to', -} - --- The role a transfer sets to take someone off the active squad. SquadTypes.ROLE_INACTIVE = 'Inactive' diff --git a/lua/wikis/commons/GameSummary.lua b/lua/wikis/commons/GameSummary.lua index df48b60cc38..e691dec070b 100644 --- a/lua/wikis/commons/GameSummary.lua +++ b/lua/wikis/commons/GameSummary.lua @@ -17,7 +17,7 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@param props {bracketId: string, matchId: string, gameIdx: integer} ---@return Html function CustomGameSummary.getGameByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch local game = match.games[props.gameIdx] diff --git a/lua/wikis/commons/GameSummary/Starcraft.lua b/lua/wikis/commons/GameSummary/Starcraft.lua index 635dba593f1..3c45fa808a6 100644 --- a/lua/wikis/commons/GameSummary/Starcraft.lua +++ b/lua/wikis/commons/GameSummary/Starcraft.lua @@ -19,7 +19,7 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@param props {bracketId: string, matchId: string, gameIdx: integer} ---@return Html function CustomGameSummary.getGameByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) --[[ + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) --[[ @as FFAMatchGroupUtilMatch]] local game = match.games[props.gameIdx] diff --git a/lua/wikis/commons/MatchGroup/Display/Bracket.lua b/lua/wikis/commons/MatchGroup/Display/Bracket.lua index e5b035fc382..4131189f2e7 100644 --- a/lua/wikis/commons/MatchGroup/Display/Bracket.lua +++ b/lua/wikis/commons/MatchGroup/Display/Bracket.lua @@ -16,6 +16,7 @@ local Opponent = Lua.import('Module:Opponent/Custom') local StringUtils = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') +local BracketUtil = Lua.import('Module:Domain/Bracket/Model') local DisplayHelper = Lua.import('Module:MatchGroup/Display/Helper') local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local WikiSpecific = Lua.import('Module:Brkts/WikiSpecific') @@ -356,7 +357,7 @@ function BracketDisplay.computeHeaderRows(bracket, config) local coords = bracket.coordinatesByMatchId[matchId] if bracketData.header then local headerRow = getHeaderRow(matchId) - local brMatch = bracketData.bracketResetMatchId and bracket.matchesById[bracketData.bracketResetMatchId] + local brMatch = BracketUtil.resetMatch(bracket.matchesById, bracketData) headerRow[coords.roundIndex] = { hasBrMatch = brMatch and true or false, header = bracketData.header, @@ -455,8 +456,7 @@ function BracketDisplay.NodeBody(props) -- Include results from bracketResetMatch ---@type MatchGroupUtilMatch? - local bracketResetMatch = match.bracketData.bracketResetMatchId - and props.matchesById[match.bracketData.bracketResetMatchId] + local bracketResetMatch = BracketUtil.resetMatch(props.matchesById, match.bracketData) if bracketResetMatch then match = Logic.wrapTryOrLog(DisplayHelper.mergeBracketResetMatch)(match, bracketResetMatch) end diff --git a/lua/wikis/commons/MatchGroup/Display/Horizontallist.lua b/lua/wikis/commons/MatchGroup/Display/Horizontallist.lua index a450b5b37e7..3afa1530fef 100644 --- a/lua/wikis/commons/MatchGroup/Display/Horizontallist.lua +++ b/lua/wikis/commons/MatchGroup/Display/Horizontallist.lua @@ -87,7 +87,7 @@ function HorizontallistDisplay.Bracket(props) classes = {'navigation-tabs__list'}, attributes = {role = 'tablist'}, children = Array.map(HorizontallistDisplay.computeHeaders(sortedBracket), function (header, index) - local attachedMatch = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, sortedBracket[index][1]) + local attachedMatch = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, sortedBracket[index][1]) local _, matchId = MatchGroupUtil.splitMatchId(attachedMatch.matchId) ---@cast matchId -nil --- If it's a matchList, then matchId is valid as is (also is numeric), otherwise we need to convert it to a key @@ -135,7 +135,7 @@ function HorizontallistDisplay.findMatchClosestInTime(bracketId, bracket) local liveGames = {} ---@type {matchIdx: integer, distanceToNow: integer}[] local otherGames = {} ---@type {matchIdx: integer, distanceToNow: integer}[] for matchIdx, matchInfo in ipairs(bracket) do - local match = MatchGroupUtil.fetchMatchForBracketDisplay(bracketId, matchInfo[1]) + local match = MatchGroupUtil.fetchMatchWithBracketReset(bracketId, matchInfo[1]) for _, game in ipairs(match.games) do local tblToInsertInto = MatchGroupUtil.computeMatchPhase(game) == 'live' and liveGames or otherGames local ts = Date.readTimestampOrNil(game.date) diff --git a/lua/wikis/commons/MatchGroup/Display/SingleGame.lua b/lua/wikis/commons/MatchGroup/Display/SingleGame.lua index 3ce29d5863b..8dbb946af34 100644 --- a/lua/wikis/commons/MatchGroup/Display/SingleGame.lua +++ b/lua/wikis/commons/MatchGroup/Display/SingleGame.lua @@ -35,7 +35,7 @@ function SingleGameDisplay.SingleGameContainer(props) assert(bracketId, 'Missing or invalid matchId') assert(props.gameIdx, 'Missing gameIdx') - local match = MatchGroupUtil.fetchMatchForBracketDisplay(bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(bracketId, props.matchId) if not match then return Html.Fragment{} diff --git a/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua b/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua index b0e0e4e3583..42b42bc9a51 100644 --- a/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua +++ b/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua @@ -35,7 +35,7 @@ function SingleMatchDisplay.SingleMatchContainer(props) assert(bracketId, 'Missing or invalid matchId') - local match = MatchGroupUtil.fetchMatchForBracketDisplay(bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(bracketId, props.matchId) return match and SingleMatchDisplay.SingleMatch({ config = props.config, diff --git a/lua/wikis/commons/MatchGroup/Util.lua b/lua/wikis/commons/MatchGroup/Util.lua index f6cb820275e..b0729457070 100644 --- a/lua/wikis/commons/MatchGroup/Util.lua +++ b/lua/wikis/commons/MatchGroup/Util.lua @@ -9,16 +9,14 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') local FnUtil = Lua.import('Module:FnUtil') -local Json = Lua.import('Module:Json') -local Operator = Lua.import('Module:Operator') local Table = Lua.import('Module:Table') -local Variables = Lua.import('Module:Variables') -local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') +local MatchGroupCoordinates = Lua.import('Module:Domain/Bracket/Coordinates') local WikiSpecific = Lua.import('Module:Brkts/WikiSpecific') -local BracketUtil = Lua.import('Module:MatchGroup/Util/Bracket') -local MatchUtil = Lua.import('Module:MatchGroup/Util/Match') +local BracketUtil = Lua.import('Module:Domain/Bracket/Model') +local MatchStore = Lua.import('Module:Domain/Match/Store') +local MatchUtil = Lua.import('Module:Domain/Match/Model') local Types = Lua.import('Module:MatchGroup/Util/Types') --[[ @@ -38,7 +36,11 @@ over, and nothing that is not a model member should be added to it. ---@class MatchGroupUtil local MatchGroupUtil = {types = Types} ---- Re-exported from Module:MatchGroup/Util/Match. Prefer importing that module directly. +--- Re-exported from Module:Domain/Match/Store. Prefer importing that module directly. +MatchGroupUtil.fetchMatchIds = MatchStore.fetchMatchIds +MatchGroupUtil.fetchMatchRecords = MatchStore.fetchMatchRecords + +--- Re-exported from Module:Domain/Match/Model. Prefer importing that module directly. MatchGroupUtil.matchFromRecord = MatchUtil.matchFromRecord MatchGroupUtil.opponentFromRecord = MatchUtil.opponentFromRecord MatchGroupUtil.createOpponent = MatchUtil.createOpponent @@ -47,7 +49,7 @@ MatchGroupUtil.gameFromRecord = MatchUtil.gameFromRecord MatchGroupUtil.groupBySubgroup = MatchUtil.groupBySubgroup MatchGroupUtil.computeMatchPhase = MatchUtil.computeMatchPhase ---- Re-exported from Module:MatchGroup/Util/Bracket. Prefer importing that module directly. +--- Re-exported from Module:Domain/Bracket/Model. Prefer importing that module directly. MatchGroupUtil.splitMatchId = BracketUtil.splitMatchId MatchGroupUtil.matchIdToKey = BracketUtil.matchIdToKey MatchGroupUtil.matchIdFromKey = BracketUtil.matchIdFromKey @@ -57,6 +59,7 @@ MatchGroupUtil.computeLowerMatchIdsFromLegacy = BracketUtil.computeLowerMatchIds MatchGroupUtil.autoAssignLowerEdges = BracketUtil.autoAssignLowerEdges MatchGroupUtil.computeAdvanceSpots = BracketUtil.computeAdvanceSpots MatchGroupUtil.populateAdvanceSpots = BracketUtil.populateAdvanceSpots +MatchGroupUtil.resetMatch = BracketUtil.resetMatch MatchGroupUtil.computeRootMatchIds = BracketUtil.computeRootMatchIds MatchGroupUtil.backfillUpperMatchIds = BracketUtil.backfillUpperMatchIds MatchGroupUtil.backfillCoordinates = BracketUtil.backfillCoordinates @@ -64,39 +67,6 @@ MatchGroupUtil.indexTableFromRecord = BracketUtil.indexTableFromRecord MatchGroupUtil.indexTableToRecord = BracketUtil.indexTableToRecord MatchGroupUtil.sectionIndexToString = BracketUtil.sectionIndexToString ----Fetches all match ids of matches that satisfy the supplied condition ----@param props {conditions: string|AbstractConditionNode, limit: string|integer?, order: string?} ----@return string[] -function MatchGroupUtil.fetchMatchIds(props) - ---@type string[] - return Array.map(mw.ext.LiquipediaDB.lpdb('match2', { - limit = tonumber(props.limit) or 1000, - query = 'match2id', - conditions = tostring(props.conditions), - order = props.order - }), Operator.property('match2id')) -end - ----Fetches all matches in a matchlist or bracket. Tries to read from page variables before fetching from LPDB. ----Returns a list of records ordered lexicographically by matchId. ----@param bracketId string ----@return table[] -function MatchGroupUtil.fetchMatchRecords(bracketId) - local varData = Variables.varDefault('match2bracket_' .. bracketId) - if varData then - return (Json.parse(varData)) - end - - return mw.ext.LiquipediaDB.lpdb( - 'match2', - { - conditions = '([[namespace::0]] or [[namespace::>0]]) AND [[match2bracketid::' .. bracketId .. ']]', - order = 'match2id ASC', - limit = 5000, - } - ) -end - MatchGroupUtil.fetchMatchGroup = FnUtil.memoize(function(bracketId) local matchRecords = MatchGroupUtil.fetchMatchRecords(bracketId) return MatchGroupUtil.makeMatchGroup(matchRecords) @@ -176,18 +146,16 @@ function MatchGroupUtil.fetchMatches(bracketId) return MatchGroupUtil.fetchMatchGroup(bracketId).matches end ----Returns a match struct for use in a bracket display or match summary popup. The bracket display and match summary ----popup expects that the finals match also include results from the bracket reset match. +---Returns a match together with its bracket reset match, if it has one. Callers that display the +---pair as a single match merge them with Module:MatchGroup/Display/Helper.mergeBracketResetMatch. ---@param bracketId string ---@param matchId string ---@return MatchGroupUtilMatch, MatchGroupUtilMatch? -function MatchGroupUtil.fetchMatchForBracketDisplay(bracketId, matchId) +function MatchGroupUtil.fetchMatchWithBracketReset(bracketId, matchId) local bracket = MatchGroupUtil.fetchMatchGroup(bracketId) local match = bracket.matchesById[matchId] - local bracketResetMatch = match - and match.bracketData.bracketResetMatchId - and bracket.matchesById[match.bracketData.bracketResetMatchId] + local bracketResetMatch = match and BracketUtil.resetMatch(bracket.matchesById, match.bracketData) return match, bracketResetMatch end diff --git a/lua/wikis/commons/MatchGroup/Util/Types.lua b/lua/wikis/commons/MatchGroup/Util/Types.lua index d9b12571e53..0b223b1c352 100644 --- a/lua/wikis/commons/MatchGroup/Util/Types.lua +++ b/lua/wikis/commons/MatchGroup/Util/Types.lua @@ -10,25 +10,19 @@ local Lua = require('Module:Lua') local TypeUtil = Lua.import('Module:TypeUtil') --[[ -The shapes of the match group model: matches, opponents, games, players, bracket data and the -match groups that hold them. +Deprecated. The runtime TypeUtil description of the match group model, reachable as +MatchGroupUtil.types and extended by a couple of per wiki modules. + +TypeUtil is on its way out, and the shapes themselves are documented as annotations on +Module:Domain/Match/Model and Module:Domain/Bracket/Model. Nothing new should be added here. ]] local Types = {} ----@class MatchGroupUtilLowerEdge ----@field lowerMatchIndex number ----@field opponentIndex number - Types.LowerEdge = TypeUtil.struct({ lowerMatchIndex = 'number', opponentIndex = 'number', }) ----@alias AdvanceBg 'up'|'stayup'|'stay'|'staydown'|'down' Types.AdvanceBg = TypeUtil.literalUnion('up', 'stayup', 'stay', 'staydown', 'down') ----@class MatchGroupUtilAdvanceSpot ----@field bg AdvanceBg ----@field matchId string? ----@field type string? Types.AdvanceSpot = TypeUtil.struct({ bg = Types.AdvanceBg, @@ -36,29 +30,6 @@ Types.AdvanceSpot = TypeUtil.struct({ type = TypeUtil.literalUnion('advance', 'custom', 'qualify'), }) ----@class MatchGroupUtilBracketBracketData ----@field coordinates MatchGroupUtilMatchCoordinates ----@field advanceSpots MatchGroupUtilAdvanceSpot[] ----@field bracketResetMatchId string? ----@field bracketType string? ----@field header string? ----@field inheritedHeader string? ----@field lowerEdges MatchGroupUtilLowerEdge[]? ----@field lowerMatchIds string[] ----@field qualLose boolean? ----@field qualLoseLiteral string? ----@field qualSkip number? ----@field qualWin boolean? ----@field qualWinLiteral string? ----@field skipRound number? ----@field thirdPlaceMatchId string? ----@field title string? ----@field type 'bracket' ----@field upperMatchId string? ----@field matchId string? ----@field matchPage string? ----@field qualifiedHeader string? - Types.BracketBracketData = TypeUtil.struct({ advanceSpots = TypeUtil.array(Types.AdvanceSpot), bracketResetMatchId = 'string?', @@ -79,17 +50,6 @@ Types.BracketBracketData = TypeUtil.struct({ type = TypeUtil.literal('bracket'), upperMatchId = 'string?', }) ----@class MatchGroupUtilMatchCoordinates ----@field depth number ----@field depthCount number ----@field matchIndexInRound number ----@field rootIndex number ----@field roundCount number ----@field roundIndex number ----@field sectionCount number ----@field sectionIndex number ----@field semanticDepth number ----@field semanticRoundIndex number Types.MatchCoordinates = TypeUtil.struct({ depth = 'number', @@ -103,13 +63,6 @@ Types.MatchCoordinates = TypeUtil.struct({ semanticDepth = 'number', semanticRoundIndex = 'number', }) ----@class MatchGroupUtilMatchlistBracketData ----@field header string? ----@field title string? ----@field dateHeader boolean? ----@field type 'matchlist' ----@field matchId string? ----@field matchPage string? Types.MatchlistBracketData = TypeUtil.struct({ header = 'string?', @@ -117,22 +70,11 @@ Types.MatchlistBracketData = TypeUtil.struct({ dateHeader = 'boolean?', type = TypeUtil.literal('matchlist'), }) ----@alias MatchGroupUtilBracketData MatchGroupUtilMatchlistBracketData|MatchGroupUtilBracketBracketData Types.BracketData = TypeUtil.union( Types.MatchlistBracketData, Types.BracketBracketData ) ----@class standardPlayer ----@field displayName string? ----@field flag string? ----@field pageName string? ----@field team string? ----@field extradata table? ----@field pageIsResolved boolean? ----@field faction string? ----@field apiId string? - Types.Player = TypeUtil.struct({ displayName = 'string?', flag = 'string?', @@ -143,25 +85,6 @@ Types.Player = TypeUtil.struct({ faction = 'string?', }) ----@class standardOpponent ----@field advanceBg string? ----@field advances boolean? ----@field icon string? ----@field icondark string? ----@field name string? ----@field placement number? ----@field placement2 number? ----@field players standardPlayer[]? ----@field score number? ----@field scoreDisplay number? ----@field score2 number? ----@field status string? ----@field status2 string? ----@field template string? ----@field type OpponentType ----@field team string? ----@field extradata table - Types.Opponent = TypeUtil.struct({ advanceBg = 'string?', advances = 'boolean?', @@ -179,12 +102,6 @@ Types.Opponent = TypeUtil.struct({ extradata = 'table', }) ----@class GameOpponent ----@field name string? ----@field players standardPlayer[] ----@field template string? ----@field type string - Types.GameOpponent = TypeUtil.struct({ name = 'string?', players = TypeUtil.optional(TypeUtil.array(Types.Player)), @@ -192,33 +109,8 @@ Types.GameOpponent = TypeUtil.struct({ type = 'string', }) ----@alias MatchStatus 'notplayed'|''|nil Types.Status = TypeUtil.optional(TypeUtil.literalUnion('notplayed', '')) ----@class MatchGroupUtilGame ----@field comment string? ----@field date string? ----@field dateIsExact boolean ----@field game string? ----@field header string? ----@field length string|number? ----@field map string? ----@field mapDisplayName string? ----@field mode string? ----@field opponents {players: table[], score: number?, status: string?}[] ----@field patch string? ----@field resultType string? ----@field scores number[] ----@field subgroup number? ----@field type string? ----@field vod string? ----@field winner integer? ----@field status string? ----@field walkover string? ----@field extradata table? ----@field timestamp number ----@field timezoneId string? - Types.Game = TypeUtil.struct({ comment = 'string?', date = 'string?', @@ -238,44 +130,6 @@ Types.Game = TypeUtil.struct({ extradata = 'table?', }) ----@class MatchGroupUtilMatch ----@field bracketData MatchGroupUtilBracketData ----@field comment string? ----@field date string ----@field dateIsExact boolean ----@field finished boolean ----@field game string? ----@field games MatchGroupUtilGame[] ----@field icon string? ----@field iconDark string? ----@field links table ----@field liquipediatier string? # TODO: camelCase ----@field liquipediatiertype string? # TODO: camelCase ----@field matchId string? ----@field mode string? ----@field opponents standardOpponent[] ----@field pageName string? ----@field parent string? ----@field patch string? ----@field phase 'upcoming'|'ongoing'|'finished' ----@field publisherTier string? ----@field resultType string? ----@field section string? ----@field series string? ----@field shortname string? ----@field status MatchStatus ----@field stream table ----@field tickername string? ----@field tournament string? ----@field type string? ----@field vod string? ----@field walkover string? ----@field winner number? ----@field extradata table? ----@field timestamp number ----@field timezoneId string? ----@field bestof number? - Types.Match = TypeUtil.struct({ bracketData = Types.BracketData, comment = 'string?', @@ -310,23 +164,6 @@ Types.Match = TypeUtil.struct({ extradata = 'table?', }) ----@class MatchGroupUtilSubgroup ----@field games MatchGroupUtilGame[] ----@field subgroup number ----@field header string? - ----@class FFAMatchGroupUtilMatch: MatchGroupUtilMatch ----@field games FFAMatchGroupUtilGame[] - ----@class FFAMatchGroupUtilGame: MatchGroupUtilGame ----@field stream table - ----@class MatchGroupUtilMatchlist ----@field bracketDatasById table ----@field matches MatchGroupUtilMatch[] ----@field matchesById table ----@field type 'matchlist' - Types.Matchlist = TypeUtil.struct({ bracketDatasById = TypeUtil.table('string', Types.BracketData), matches = TypeUtil.array(Types.Match), @@ -334,16 +171,6 @@ Types.Matchlist = TypeUtil.struct({ type = TypeUtil.literal('matchlist'), }) ----@class MatchGroupUtilBracket ----@field bracketDatasById table ----@field coordinatesByMatchId table ----@field matches MatchGroupUtilMatch[] ----@field matchesById table ----@field rootMatchIds string[] ----@field rounds string[][] ----@field sections string[][] ----@field type 'bracket' - Types.Bracket = TypeUtil.struct({ bracketDatasById = TypeUtil.table('string', Types.BracketData), coordinatesByMatchId = TypeUtil.table('string', Types.MatchCoordinates), @@ -355,18 +182,9 @@ Types.Bracket = TypeUtil.struct({ type = TypeUtil.literal('bracket'), }) ----@alias MatchGroupUtilMatchGroup MatchGroupUtilBracket|MatchGroupUtilMatchlist Types.MatchGroup = TypeUtil.union( Types.Matchlist, Types.Bracket ) ---- The subset of a match or game record that is enough to work out its phase. ----@class PartialMatchGameRecord ----@field date string ----@field dateexact boolean|string|nil # records carry '0'/'1' ----@field timestamp number? ----@field finished boolean? ----@field winner integer? - return Types diff --git a/lua/wikis/commons/MatchSummary/Base.lua b/lua/wikis/commons/MatchSummary/Base.lua index 687a2911b2f..e753eb14e40 100644 --- a/lua/wikis/commons/MatchSummary/Base.lua +++ b/lua/wikis/commons/MatchSummary/Base.lua @@ -213,7 +213,7 @@ function MatchSummary.defaultGetByMatchId(CustomMatchSummary, args, options) options = options or {} - local match, bracketResetMatch = MatchGroupUtil.fetchMatchForBracketDisplay( + local match, bracketResetMatch = MatchGroupUtil.fetchMatchWithBracketReset( args.bracketId, args.matchId) ---@type (fun(match: MatchGroupUtilMatch):string?)|string|integer? diff --git a/lua/wikis/commons/MatchSummary/Starcraft/Ffa.lua b/lua/wikis/commons/MatchSummary/Starcraft/Ffa.lua index 5b8134e9dbd..28cd1119dad 100644 --- a/lua/wikis/commons/MatchSummary/Starcraft/Ffa.lua +++ b/lua/wikis/commons/MatchSummary/Starcraft/Ffa.lua @@ -27,7 +27,7 @@ local Parser = {} ---@param props {bracketId: string, matchId: string} ---@return Renderable function StarcraftMatchSummaryFfa.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch BaseMatchSummary.updateMatchOpponents(match) diff --git a/lua/wikis/commons/PrizePool/Import.lua b/lua/wikis/commons/PrizePool/Import.lua index 538d335253a..ebd1e4d1574 100644 --- a/lua/wikis/commons/PrizePool/Import.lua +++ b/lua/wikis/commons/PrizePool/Import.lua @@ -17,7 +17,7 @@ local String = Lua.import('Module:StringUtils') local Table = Lua.import('Module:Table') local TournamentStructure = Lua.import('Module:TournamentStructure') -local MatchGroupCoordinates = Lua.import('Module:MatchGroup/Coordinates') +local MatchGroupCoordinates = Lua.import('Module:Domain/Bracket/Coordinates') local MatchGroupUtil = Lua.import('Module:MatchGroup/Util/Custom') local Placement = Lua.import('Module:PrizePool/Placement') diff --git a/lua/wikis/counterstrike/MatchSummary/Ffa.lua b/lua/wikis/counterstrike/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/counterstrike/MatchSummary/Ffa.lua +++ b/lua/wikis/counterstrike/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/deltaforce/MatchSummary/Ffa.lua b/lua/wikis/deltaforce/MatchSummary/Ffa.lua index 7d5f0dfc0cf..c3d4de6643c 100644 --- a/lua/wikis/deltaforce/MatchSummary/Ffa.lua +++ b/lua/wikis/deltaforce/MatchSummary/Ffa.lua @@ -20,7 +20,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@return Widget function CustomMatchSummary.getByMatchId(props) ---@class FFAMatchGroupUtilMatch - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/fortnite/MatchSummary/Ffa.lua b/lua/wikis/fortnite/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/fortnite/MatchSummary/Ffa.lua +++ b/lua/wikis/fortnite/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/freefire/MatchSummary/Ffa.lua b/lua/wikis/freefire/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/freefire/MatchSummary/Ffa.lua +++ b/lua/wikis/freefire/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/geoguessr/GameSummary.lua b/lua/wikis/geoguessr/GameSummary.lua index 3d6d52a038a..a7d161843ed 100644 --- a/lua/wikis/geoguessr/GameSummary.lua +++ b/lua/wikis/geoguessr/GameSummary.lua @@ -20,7 +20,7 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@return Html function CustomGameSummary.getGameByMatchId(props) ---@type FFAMatchGroupUtilMatch - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) local game = match.games[props.gameIdx] assert(game, 'Error Game ID ' .. tostring(props.gameIdx) .. ' not found') diff --git a/lua/wikis/geoguessr/MatchSummary/Ffa.lua b/lua/wikis/geoguessr/MatchSummary/Ffa.lua index 1ca06ea9190..5f58cb4dd97 100644 --- a/lua/wikis/geoguessr/MatchSummary/Ffa.lua +++ b/lua/wikis/geoguessr/MatchSummary/Ffa.lua @@ -23,7 +23,7 @@ local Parser = {} ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/halo/MatchSummary/Ffa.lua b/lua/wikis/halo/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/halo/MatchSummary/Ffa.lua +++ b/lua/wikis/halo/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/hearthstone/MatchSummary/Ffa.lua b/lua/wikis/hearthstone/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/hearthstone/MatchSummary/Ffa.lua +++ b/lua/wikis/hearthstone/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/lab/MatchSummary/Ffa.lua b/lua/wikis/lab/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/lab/MatchSummary/Ffa.lua +++ b/lua/wikis/lab/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/naraka/MatchSummary/Ffa.lua b/lua/wikis/naraka/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/naraka/MatchSummary/Ffa.lua +++ b/lua/wikis/naraka/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/pubg/MatchSummary/Ffa.lua b/lua/wikis/pubg/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/pubg/MatchSummary/Ffa.lua +++ b/lua/wikis/pubg/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/pubgmobile/MatchSummary/Ffa.lua b/lua/wikis/pubgmobile/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/pubgmobile/MatchSummary/Ffa.lua +++ b/lua/wikis/pubgmobile/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/tarkovarena/MatchSummary/Ffa.lua b/lua/wikis/tarkovarena/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/tarkovarena/MatchSummary/Ffa.lua +++ b/lua/wikis/tarkovarena/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/tft/MatchSummary/Ffa.lua b/lua/wikis/tft/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/tft/MatchSummary/Ffa.lua +++ b/lua/wikis/tft/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/thefinals/GameSummary.lua b/lua/wikis/thefinals/GameSummary.lua index 340454f1665..9797fc77925 100644 --- a/lua/wikis/thefinals/GameSummary.lua +++ b/lua/wikis/thefinals/GameSummary.lua @@ -19,7 +19,7 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@param props {bracketId: string, matchId: string, gameIdx: integer} ---@return Html function CustomGameSummary.getGameByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) --[[ + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) --[[ @as FFAMatchGroupUtilMatch]] local game = match.games[props.gameIdx] diff --git a/lua/wikis/thefinals/MatchSummary/Ffa.lua b/lua/wikis/thefinals/MatchSummary/Ffa.lua index cad2351421f..6f3e7087e23 100644 --- a/lua/wikis/thefinals/MatchSummary/Ffa.lua +++ b/lua/wikis/thefinals/MatchSummary/Ffa.lua @@ -23,7 +23,7 @@ local Parser = {} ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/trackmania/MatchSummary/Ffa.lua b/lua/wikis/trackmania/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/trackmania/MatchSummary/Ffa.lua +++ b/lua/wikis/trackmania/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/underlords/MatchSummary/Ffa.lua b/lua/wikis/underlords/MatchSummary/Ffa.lua index e4e4830081f..aad3b1ef729 100644 --- a/lua/wikis/underlords/MatchSummary/Ffa.lua +++ b/lua/wikis/underlords/MatchSummary/Ffa.lua @@ -19,7 +19,7 @@ local WidgetUtil = Lua.import('Module:Widget/Util') ---@param props {bracketId: string, matchId: string} ---@return Renderable function CustomMatchSummary.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch SummaryHelper.updateMatchOpponents(match) local scoringData = SummaryHelper.createScoringData(match) diff --git a/lua/wikis/warcraft/GameSummary.lua b/lua/wikis/warcraft/GameSummary.lua index dcf7020596e..356d408abbb 100644 --- a/lua/wikis/warcraft/GameSummary.lua +++ b/lua/wikis/warcraft/GameSummary.lua @@ -22,7 +22,7 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@param props {bracketId: string, matchId: string, gameIdx: integer} ---@return Html function CustomGameSummary.getGameByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) --[[ + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) --[[ @as FFAMatchGroupUtilMatch]] local game = match.games[props.gameIdx] diff --git a/lua/wikis/warcraft/MatchSummary/Ffa.lua b/lua/wikis/warcraft/MatchSummary/Ffa.lua index e02f6da676c..64e8e583231 100644 --- a/lua/wikis/warcraft/MatchSummary/Ffa.lua +++ b/lua/wikis/warcraft/MatchSummary/Ffa.lua @@ -27,7 +27,7 @@ local Parser = {} ---@param props {bracketId: string, matchId: string} ---@return Renderable function WarcraftMatchSummaryFfa.getByMatchId(props) - local match = MatchGroupUtil.fetchMatchForBracketDisplay(props.bracketId, props.matchId) + local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) ---@cast match FFAMatchGroupUtilMatch BaseMatchSummary.updateMatchOpponents(match) diff --git a/scripts/check_lua_imports.py b/scripts/check_lua_imports.py new file mode 100644 index 00000000000..da9e15c5940 --- /dev/null +++ b/scripts/check_lua_imports.py @@ -0,0 +1,138 @@ +"""Check the layering rules from the bulletproof-lua RFC. + +Three layers, and imports only point down: features -> domain -> shared. Within a feature they +point down too: an entry point may reach the controller, the controller may reach +Components/Api/Lib, and those may reach Types. + +Only the parts of the tree that have been moved onto the structure are classified. Everything +that has not been restructured yet is "unclassified", and is only used to detect reaching into a +feature's internals from outside; it is not itself held to the layer rules. + +Enforcement is graduated. Code that has been moved onto the structure is held to the rules and +fails the build, because a regression there is a regression in work already done. Unclassified code +only warns, because it has not been migrated yet; those warnings become errors as each area moves. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +LUA_ROOT = pathlib.Path("lua/wikis") +IMPORT_RE = re.compile( + r"""(?:Lua\.import|Lua\.requireIfExists|require)\(\s*['"]Module:([^'"]+)['"]""" +) + +# Feature tiers, lowest first. Modules directly in the feature folder are entry points. +TIERS = {"Types": 0, "Lib": 1, "Api": 1, "Components": 1, "Controller": 2} +ENTRY_TIER = 3 + +# Domain holds the site's entities and their rules. It may read per wiki *config* (Info.config is +# data, and every rule read from it must be overridable by a parameter so the entity stays +# testable), but it may not render, and it may not reach for per wiki *code*: that is a feature +# concern and it cannot be substituted in a test. +DOMAIN_FORBIDDEN_ROOTS = {"Widget": "renders"} +DOMAIN_FORBIDDEN_PATTERNS = { + "Brkts/WikiSpecific": "reaches for per wiki code", + "/Custom": "reaches for per wiki code", +} + + +class Module: + """A lua module, classified by where it sits in the layering.""" + + def __init__(self, path: pathlib.Path): + self.path = path + parts = path.relative_to(LUA_ROOT).with_suffix("").parts + self.name = "/".join(parts[1:]) + self.wiki = parts[0] + self.layer, self.feature, self.tier, self.group = classify("/".join(parts[1:])) + + def imports(self) -> list[str]: + return IMPORT_RE.findall(self.path.read_text(encoding="utf-8")) + + +def classify(name: str) -> tuple[str, str | None, int | None, str | None]: + """Classify a module name into layer, feature, tier and tier folder.""" + parts = name.split("/") + if parts[0] == "Features" and len(parts) > 1: + rest = parts[2:] + # Types.lua and Controller.lua are tiers that sit directly in the feature folder; + # anything else there (Custom.lua, Auto.lua) is an entry point. + group = rest[0] if rest and rest[0] in TIERS else None + tier = TIERS[group] if group else ENTRY_TIER + return "feature", parts[1], tier, group + if parts[0] == "Domain": + return "domain", None, None, None + return "unclassified", None, None, None + + +def violations(module: Module) -> list[str]: + found = [] + for name in module.imports(): + layer, feature, tier, group = classify(name) + + if layer == "feature" and feature != module.feature and tier != ENTRY_TIER: + found.append(f"reaches into {feature}'s internals: Module:{name}") + + if ( + layer == "feature" + and feature != module.feature + and module.layer == "feature" + ): + found.append(f"imports another feature: Module:{name}") + + if layer == "feature" and module.layer == "domain": + found.append(f"domain must not import a feature: Module:{name}") + + if module.layer == "domain": + reason = DOMAIN_FORBIDDEN_ROOTS.get(name.split("/")[0]) + for pattern, why in DOMAIN_FORBIDDEN_PATTERNS.items(): + if pattern in name: + reason = why + if reason: + found.append(f"domain {reason}: Module:{name}") + + if ( + layer == "feature" + and feature == module.feature + and None not in (tier, module.tier) + ): + # Peers inside one tier folder may compose each other; crossing between them may not. + if tier > module.tier: + found.append(f"imports upward within {feature}: Module:{name}") + elif tier == module.tier and group != module.group: + found.append(f"imports sideways within {feature}: Module:{name}") + + return found + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args() + + errors, warnings = [], [] + for path in sorted(LUA_ROOT.rglob("*.lua")): + module = Module(path) + # A violation coming out of restructured code breaks work already done, so it fails. + bucket = warnings if module.layer == "unclassified" else errors + for message in violations(module): + bucket.append(f"{path}: {message}") + + for message in errors: + print(f"error: {message}") + for message in warnings: + print(f"warning: {message}") + + if not errors and not warnings: + print("No import rule violations.") + else: + print(f"\n{len(errors)} error(s), {len(warnings)} warning(s).") + + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 203fcac7dc662fed72251cfa26adc2bd447521b1 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Fri, 18 Sep 2026 08:50:22 +0000 Subject: [PATCH 3/5] fix annos --- lua/wikis/commons/Bracket/Template.lua | 2 +- lua/wikis/commons/MatchGroup/Display/SingleMatch.lua | 4 ++-- lua/wikis/geoguessr/GameSummary.lua | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/wikis/commons/Bracket/Template.lua b/lua/wikis/commons/Bracket/Template.lua index 31ce9c761fc..fe03a925eea 100644 --- a/lua/wikis/commons/Bracket/Template.lua +++ b/lua/wikis/commons/Bracket/Template.lua @@ -55,7 +55,7 @@ For copy-pastable code please use Special:RunQuery/BracketCopyPaste end ---@param props {bracketId: string, config: table?} ----@return Html +---@return Renderable function BracketTemplate.BracketContainer(props) local matchRecords = MatchGroupUtil.fetchMatchRecords(props.bracketId) Array.forEach(matchRecords, function(match) diff --git a/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua b/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua index 42b42bc9a51..294336346b6 100644 --- a/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua +++ b/lua/wikis/commons/MatchGroup/Display/SingleMatch.lua @@ -29,7 +29,7 @@ end ---Display component for a singleMatch. The singleMatch is specified by matchID. ---The component fetches the match data from LPDB or page variables. ---@param props {matchId: string, config: SingleMatchConfigOptions} ----@return Widget|Html +---@return Renderable function SingleMatchDisplay.SingleMatchContainer(props) local bracketId, _ = MatchGroupUtil.splitMatchId(props.matchId) @@ -46,7 +46,7 @@ end ---Display component for a singleMatch. Match data is specified in the input. ---@param props {config: SingleMatchConfigOptions, match: MatchGroupUtilMatch} ----@return Widget|Html +---@return Renderable function SingleMatchDisplay.SingleMatch(props) local propsConfig = props.config or {} local MatchSummaryContainer = propsConfig.MatchSummaryContainer or DisplayHelper.DefaultMatchSummaryContainer diff --git a/lua/wikis/geoguessr/GameSummary.lua b/lua/wikis/geoguessr/GameSummary.lua index a7d161843ed..98a80b3f0e5 100644 --- a/lua/wikis/geoguessr/GameSummary.lua +++ b/lua/wikis/geoguessr/GameSummary.lua @@ -19,8 +19,8 @@ local MatchSummaryWidgets = Lua.import('Module:Widget/Match/Summary/Ffa/All') ---@param props {bracketId: string, matchId: string, gameIdx: integer} ---@return Html function CustomGameSummary.getGameByMatchId(props) - ---@type FFAMatchGroupUtilMatch local match = MatchGroupUtil.fetchMatchWithBracketReset(props.bracketId, props.matchId) + ---@cast match FFAMatchGroupUtilMatch local game = match.games[props.gameIdx] assert(game, 'Error Game ID ' .. tostring(props.gameIdx) .. ' not found') From 0cc5f2a334f5ce04a19ec0824065cc491666c267 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Mon, 5 Oct 2026 13:47:31 +0200 Subject: [PATCH 4/5] ci: annotate import rule violations on the diff The import check only wrote to the raw job log, so a violation was something you had to go looking for. Each one now carries the line and column of the import that broke the rule, and is printed as a workflow command on GitHub so it lands on the diff, the way the LuaLS job already does. Anywhere else it stays plain text, on stderr. Also brings the job in line with the other workflows: setup-python v7 pinned to 3.14, and PYTHONUNBUFFERED so the output is not held back from the log. Co-Authored-By: Claude Opus 5 --- .github/workflows/luacheck.yml | 6 +- .../bracket_spec.lua} | 0 .../match_spec.lua} | 0 .../team_history_spec.lua} | 0 scripts/check_lua_imports.py | 81 ++++++++++++++----- 5 files changed, 67 insertions(+), 20 deletions(-) rename lua/spec/{domain_bracket_spec.lua => domain/bracket_spec.lua} (100%) rename lua/spec/{domain_match_spec.lua => domain/match_spec.lua} (100%) rename lua/spec/{domain_team_history_spec.lua => domain/team_history_spec.lua} (100%) diff --git a/.github/workflows/luacheck.yml b/.github/workflows/luacheck.yml index bc047da4445..8a883545c85 100644 --- a/.github/workflows/luacheck.yml +++ b/.github/workflows/luacheck.yml @@ -43,14 +43,16 @@ jobs: uses: actions/checkout@v7 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: - python-version: '3.x' + python-version: '3.14' # Fails on violations coming out of Features/ and Domain/, warns for code that has not # been restructured yet. Those warnings become errors as each area is migrated. - name: Check import rules run: python scripts/check_lua_imports.py + env: + PYTHONUNBUFFERED: 1 lua-unit-test: runs-on: ubuntu-latest diff --git a/lua/spec/domain_bracket_spec.lua b/lua/spec/domain/bracket_spec.lua similarity index 100% rename from lua/spec/domain_bracket_spec.lua rename to lua/spec/domain/bracket_spec.lua diff --git a/lua/spec/domain_match_spec.lua b/lua/spec/domain/match_spec.lua similarity index 100% rename from lua/spec/domain_match_spec.lua rename to lua/spec/domain/match_spec.lua diff --git a/lua/spec/domain_team_history_spec.lua b/lua/spec/domain/team_history_spec.lua similarity index 100% rename from lua/spec/domain_team_history_spec.lua rename to lua/spec/domain/team_history_spec.lua diff --git a/scripts/check_lua_imports.py b/scripts/check_lua_imports.py index da9e15c5940..8be3873d2a8 100644 --- a/scripts/check_lua_imports.py +++ b/scripts/check_lua_imports.py @@ -11,14 +11,19 @@ Enforcement is graduated. Code that has been moved onto the structure is held to the rules and fails the build, because a regression there is a regression in work already done. Unclassified code only warns, because it has not been migrated yet; those warnings become errors as each area moves. + +On GitHub Actions each violation is printed as a workflow command so it lands on the diff of the +offending line. Run anywhere else it is plain text on stderr instead. """ from __future__ import annotations import argparse +import os import pathlib import re import sys +from typing import NamedTuple LUA_ROOT = pathlib.Path("lua/wikis") IMPORT_RE = re.compile( @@ -40,6 +45,15 @@ } +class Violation(NamedTuple): + """One broken rule, located at the import that broke it.""" + + path: pathlib.Path + line: int + column: int + message: str + + class Module: """A lua module, classified by where it sits in the layering.""" @@ -50,8 +64,15 @@ def __init__(self, path: pathlib.Path): self.wiki = parts[0] self.layer, self.feature, self.tier, self.group = classify("/".join(parts[1:])) - def imports(self) -> list[str]: - return IMPORT_RE.findall(self.path.read_text(encoding="utf-8")) + def imports(self) -> list[tuple[str, int, int]]: + """Every imported module name, with the line and column it is imported on.""" + text = self.path.read_text(encoding="utf-8") + found = [] + for match in IMPORT_RE.finditer(text): + line = text.count("\n", 0, match.start()) + 1 + column = match.start() - text.rfind("\n", 0, match.start()) + found.append((match.group(1), line, column)) + return found def classify(name: str) -> tuple[str, str | None, int | None, str | None]: @@ -69,23 +90,26 @@ def classify(name: str) -> tuple[str, str | None, int | None, str | None]: return "unclassified", None, None, None -def violations(module: Module) -> list[str]: +def violations(module: Module) -> list[Violation]: found = [] - for name in module.imports(): + for name, line, column in module.imports(): layer, feature, tier, group = classify(name) + def report(message: str) -> None: + found.append(Violation(module.path, line, column, message)) + if layer == "feature" and feature != module.feature and tier != ENTRY_TIER: - found.append(f"reaches into {feature}'s internals: Module:{name}") + report(f"reaches into {feature}'s internals: Module:{name}") if ( layer == "feature" and feature != module.feature and module.layer == "feature" ): - found.append(f"imports another feature: Module:{name}") + report(f"imports another feature: Module:{name}") if layer == "feature" and module.layer == "domain": - found.append(f"domain must not import a feature: Module:{name}") + report(f"domain must not import a feature: Module:{name}") if module.layer == "domain": reason = DOMAIN_FORBIDDEN_ROOTS.get(name.split("/")[0]) @@ -93,7 +117,7 @@ def violations(module: Module) -> list[str]: if pattern in name: reason = why if reason: - found.append(f"domain {reason}: Module:{name}") + report(f"domain {reason}: Module:{name}") if ( layer == "feature" @@ -102,29 +126,50 @@ def violations(module: Module) -> list[str]: ): # Peers inside one tier folder may compose each other; crossing between them may not. if tier > module.tier: - found.append(f"imports upward within {feature}: Module:{name}") + report(f"imports upward within {feature}: Module:{name}") elif tier == module.tier and group != module.group: - found.append(f"imports sideways within {feature}: Module:{name}") + report(f"imports sideways within {feature}: Module:{name}") return found +def report_github(level: str, violation: Violation) -> None: + """Print a workflow command, so the violation is annotated on the line that caused it.""" + # Workflow commands are read off stdout, and the data in them has to be escaped. + message = violation.message + for char, replacement in (("%", "%25"), ("\r", "%0D"), ("\n", "%0A")): + message = message.replace(char, replacement) + print( + f"::{level} file={violation.path}," + f"line={violation.line},col={violation.column}::{message}" + ) + + +def report_plain(level: str, violation: Violation) -> None: + print( + f"{level}: {violation.path}:{violation.line}: {violation.message}", + file=sys.stderr, + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.parse_args() - errors, warnings = [], [] + errors: list[Violation] = [] + warnings: list[Violation] = [] for path in sorted(LUA_ROOT.rglob("*.lua")): module = Module(path) # A violation coming out of restructured code breaks work already done, so it fails. bucket = warnings if module.layer == "unclassified" else errors - for message in violations(module): - bucket.append(f"{path}: {message}") - - for message in errors: - print(f"error: {message}") - for message in warnings: - print(f"warning: {message}") + bucket.extend(violations(module)) + + report = ( + report_github if os.environ.get("GITHUB_ACTIONS") == "true" else report_plain + ) + for level, found in (("error", errors), ("warning", warnings)): + for violation in found: + report(level, violation) if not errors and not warnings: print("No import rule violations.") From d661c4c0b243e281f992f3089a29fe9391be80f3 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Mon, 5 Oct 2026 13:47:31 +0200 Subject: [PATCH 5/5] test: move the domain specs into spec/domain The spec folder is flat at 77 files. The domain specs already spelled the hierarchy out in their names, so they become spec/domain/_spec.lua. The luacheck pattern that grants specs their test globals had to learn to cross a folder boundary to follow them. Co-Authored-By: Claude Opus 5 --- lua/.luacheckrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/.luacheckrc b/lua/.luacheckrc index 511a6789066..5ad3f65a42d 100644 --- a/lua/.luacheckrc +++ b/lua/.luacheckrc @@ -42,4 +42,4 @@ ignore = { "212" -- unused argument } -files["lua/spec/*_spec.lua"].read_globals = {"GoldenTest", "SetActiveWiki", "allwikis"} +files["lua/spec/**/*_spec.lua"].read_globals = {"GoldenTest", "SetActiveWiki", "allwikis"}