Module:MM Schedule

-- Terminology Schedule: An ordered list of entries Entry: An event, day state, or moment Event: A Bomber's Notebook event (see Event) Day state: "Dawn of the First Day", "Night of the Final Day", etc. Moment: Something a character does at a particular time

local p = {} local h = {} local Data = require("Module:MM Schedule/Data")

local DataTable = require("Module:Data Table") local Term = require("Module:Term") local utilsArg = require("Module:UtilsArg") local utilsLayout = require("Module:UtilsLayout") local utilsMarkup = require("Module:UtilsMarkup") local utilsString = require("Module:UtilsString") local utilsTable = require("Module:UtilsTable")

local CATEGORY_INVALID_ARGS = require("Module:Constants/category/invalidArgs")

function h.warn(msg, ...) local utilsError = require("Module:UtilsError") msg = string.format(msg, ...) utilsError.warn(msg) end

-- Template:MM Schedule 2 (WIP) function p.Main(frame) local args, err = utilsArg.parse(frame:getParent.args, p.Templates["MM Schedule"]) local categories = err and err.categoryText or "" local rows = DataTable.parseRows(args.schedule) local scheduleEntries = utilsTable.map(rows, "cells") local schedule, categories2 = h.parseSchedule(scheduleEntries) local defaultBranch = args.defaultBranch and h.parseDefaultBranch(args.defaultBranch) local scheduleOutput = h.printSchedule(schedule, defaultBranch) categories = categories..categories2

return scheduleOutput, categories end

function h.parseSchedule(entries) local categories = "" local schedule = {} local events = {} for i, entryInput in ipairs(entries) do		local entry, entryEvents, errCategories = h.parseEntry(entryInput) categories = categories..errCategories if errCategories == "" then table.insert(schedule, entry) h.addForkEvents(events, entryEvents) end end events = utilsTable.uniqueBy(events, function(event)		return event.forkEvent.name	end)

schedule = utilsTable.concat(events, schedule) schedule = utilsTable.sortBy(schedule, {"epochMinutes", function(entry)		if entry.forkEvent then			return 1		elseif entry.dayStateChange then			return 2		elseif entry.moment then			return 3		end	end}) return schedule, categories end

function h.parseDefaultBranch(input) local preconditions = h.parsePreconditions(input) return h.preconditions(preconditions) end

local currentDayState function h.parseEntry(entryInput) local entry = {} local events = {} local categories = "" local preconditions if #entryInput == 4 then local preconditionsInput = table.remove(entryInput, 1) preconditions, events, categories = h.parsePreconditions(preconditionsInput) end if #entryInput == 3 then local time = entryInput[1] local epochMinutes = h.epochMinutes(currentDayState.. " "..time) if not epochMinutes then h.warn("Invalid time entry: %s. See Template:MM Schedule for correct time format.", time) categories = "" end local location = entryInput[2] local actions = entryInput[3] entry.epochMinutes = epochMinutes entry.preconditions = preconditions entry.moment = { time = time, location = location, actions = actions, }	elseif #entryInput == 1 then local dayState = entryInput[1] local dayStateCode = Data.dayStates[dayState] if dayStateCode then currentDayState = dayStateCode else local dayStates = utilsTable.keys(Data.dayStates) local dayStateList = utilsMarkup.bulletList(dayStates) h.warn("Invalid day state . Accepted day states are: %s", dayState, dayStateList) categories = "" end entry.dayStateChange = dayState entry.epochMinutes = h.dayStateToMinutes(dayStateCode) else h.warn("Invalid entry: %s", utilsTable.print(entryInput)) entry = nil categories = "" end return entry, events, categories end

local seenEvents = {} function h.addForkEvents(entries, events) for i, event in ipairs(events) do		if not seenEvents[event.name] then local forkEventEntry = { epochMinutes = h.epochMinutes(event.sortTime), preconditions = h.preconditions(event.preconditions), forkEvent = { name = event.name, description = event.description, notes = event.notes, }			}			table.insert(entries, forkEventEntry) seenEvents[event.name] = event end end end

function h.parsePreconditions(conditions) local events = {} local preconditions local categories = "" for condition in string.gmatch(conditions, "%[([^%]]+)%]") do		local operator = string.match(condition, "^([^:]+):") local eventName = string.gsub(condition, "^([^:]+):", "") local event = Data.events[eventName] local eventState = h.eventState(operator) if event == nil then h.warn("Unrecognized event name . See Module:MM Schedule/Data for supported events.", eventName) categories = categories.."" else preconditions = preconditions or {} preconditions[eventName] = eventState event = utilsTable.merge({}, event, { name = eventName }) table.insert(events, event) end end preconditions = h.preconditions(preconditions) return preconditions, events, categories end function h.eventState(operator) if operator == nil then return true elseif operator == "Not" then return false else return operator end end

-- Computes transitive preconditions and adds them to the table -- For example, Anju's Anguish (1) is a precondition for Anju's Angust (2) which is a precondition for Anju's Anguish (3) -- so Anju's Anguish (1) is a transivite precondition for Anju's Anguish (3) function h.preconditions(preconditions) preconditions = preconditions or {} for eventName in pairs(preconditions or {}) do		if preconditions[eventName] == true then local transitivePreconditions = Data.events[eventName].preconditions transitivePreconditions = h.preconditions(transitivePreconditions) preconditions = utilsTable.merge({}, preconditions, transitivePreconditions) end end return preconditions end

-- @param timeStr a string such as "D2 12:00 PM" -- @return an integer representation of the time in minutes since the Dawn of the First Day, or nil if timeStr is invalid function h.epochMinutes(timeStr) local categories = "" local isValid = string.match(timeStr, "^[DN][1-3] 1?[0-9]:[0-5][0-9] [AP]M") if not isValid then return nil end local timeParts = utilsString.split(timeStr, " ") local dayState = timeParts[1] local dayPeriod = timeParts[3] local hours, minutes = unpack(utilsString.split(timeParts[2], ":")) hours = tonumber(hours) minutes = tonumber(minutes)

local totalMinutes = h.dayStateToMinutes(dayState) if hours < 6 then totalMinutes = totalMinutes + 6*60 + hours*60 elseif hours > 6 then totalMinutes = totalMinutes + (hours-6)*60 end totalMinutes = totalMinutes + minutes return totalMinutes end

function h.dayStateToMinutes(dayState) dayState = utilsString.split(dayState, "") local dayNum = tonumber(dayState[2]) local minutes = (dayNum-1)*24*60 -- offset by 24 hours for each day if dayState[1] == "N" then minutes = minutes + 12*60 -- offset by an additional 12 hours for nights end return minutes end

function h.printSchedule(schedule, defaultBranch, isBranch) local entries = utilsTable.takeWhile(schedule, function(entry)		return not entry.forkEvent	end)

local forkEvents = {} for i = #entries + 1, #schedule do		if not schedule[i].forkEvent then break else table.insert(forkEvents, schedule[i].forkEvent) end end

local branches if #forkEvents > 0 then local forkEntries = utilsTable.slice(schedule, #entries + #forkEvents + 1) branches = h.scheduleBranches(forkEntries, forkEvents, defaultBranch) -- Don't show branch tabs when there is only one or the tabs are the same if #branches < 2 then entries = utilsTable.concat(entries, forkEntries) branches = nil end end local html = mw.html.create("div") local tabl = html:tag("table"):addClass("wikitable") if not isBranch then html:addClass("mm-schedule") end -- Show "Dawn of the First Day before column headers" if not isBranch and entries[1].dayStateChange then tabl:tag("tr") :tag("th") :attr("colspan", 3) :wikitext(entries[1].dayStateChange) table.remove(entries, 1) end if not isBranch then tabl:tag("tr") :tag("th") :wikitext("Time") :done :tag("th") :wikitext("Location") :done :tag("th") :wikitext("Action(s)") :done end for i, entry in ipairs(entries) do		local trow = tabl:tag("tr") if entry.dayStateChange then trow:tag("th") :attr("colspan", 3) :wikitext(entry.dayStateChange) :done elseif entry.moment then local moment = entry.moment local locationInput = moment.location local location if utilsMarkup.containsLink(locationInput) then location = locationInput elseif locationInput == "N/A" then location = mw.getCurrentFrame:expandTemplate({ title = "NA" }) else local locationSubject, locationNotes = utilsMarkup.separateMarkup(locationInput) local locationLink = Term.link(locationSubject, "MM3D") location = locationLink..locationNotes end trow:tag("td") :wikitext(moment.time) :addClass("mm-schedule__time") :done :tag("td") :wikitext(location) :addClass("mm-schedule__location") :done :tag("td") :addClass("mm-schedule__actions") :wikitext(moment.actions) :done end end if branches then local tabData = {} for i, branch in ipairs(branches) do			local tabLabel = h.branchLabel(branch.conditions) local tabContent = h.printSchedule(branch.schedule, defaultBranch, true) table.insert(tabData, {				label = tabLabel,				content = tabContent,			}) end local tabs = utilsLayout.tabs(tabData) html:wikitext(tabs) end return tostring(html) end function h.branchLabel(conditions) local listItems = {} for i, condition in ipairs(conditions) do		local eventName, eventState = unpack(condition) local image if eventState == true then image = "" elseif eventState == false then image = "" else image = eventState end table.insert(listItems, image.." "..eventName) end if #listItems == 1 then return listItems[1] else return utilsMarkup.list(listItems) end end

-- Generally, each "fork" in a character's schedule is determined by one event being completed or not. -- However, there are a few events happen at around the same time, so we want to show these two events in the same tab group. function h.scheduleBranches(schedule, events, defaultBranch) local conditionCombinations = h.conditionCombinations(schedule, events) local branches = {} for i, conditions in ipairs(conditionCombinations) do		local branch = { conditions = conditions, schedule = h.filterByPreconditions(schedule, conditions), }		table.insert(branches, branch) end branches = h.mergeBranches(branches) branches = h.setDefaultBranch(branches, defaultBranch) return branches end

function h.mergeBranches(branches) local branchesToRemove = {} for i, branchA in ipairs(utilsTable.clone(branches)) do		for j, branchB in ipairs(utilsTable.clone(branches)) do			if i ~= j and h.schedulesEqual(branchA.schedule, branchB.schedule) then local conditions = utilsTable.intersection(branchA.conditions, branchB.conditions) if #conditions > 0 then local mergedBranch = { conditions = conditions, schedule = branchA.schedule }					table.insert(branches, mergedBranch) branches[i] = nil branches[j] = nil end end end end return utilsTable.compact(branches) end function h.schedulesEqual(scheduleA, scheduleB) local momentsA = utilsTable.filter(scheduleA, "moment") local momentsB = utilsTable.filter(scheduleB, "moment") return utilsTable.isEqual(momentsA, momentsB) end

function h.setDefaultBranch(branches, defaultBranch) local defaultBranchIndex = defaultBranch and utilsTable.findIndex(branches, function(branch)		for i, condition in ipairs(branch.conditions) do			local eventName, eventState = unpack(condition)			if eventState ~= defaultBranch[eventName] then				return false			end		end		return true	end) if not defaultBranchIndex then -- get longest branch local longestBranchLength = 0 for i = 1, #branches do			local branchLength = #branches[i].schedule if branchLength > longestBranchLength then longestBranchLength = branchLength defaultBranchIndex = i			end end end if defaultBranchIndex then local branch = table.remove(branches, defaultBranchIndex) table.insert(branches, 1, branch) end return branches end

function h.conditionCombinations(schedule, events) local specialStates = {} for i, entry in ipairs(schedule) do		for eventName, eventState in pairs(entry.preconditions or {}) do			if type(eventState) == "string" then specialStates[eventName] = eventState end end end

local conditions = {} for i, event in ipairs(events) do		local eventConditions = {} table.insert(eventConditions, {event.name, true}) table.insert(eventConditions, {event.name, false}) local specialState = specialStates[event.name] if specialState then table.insert(eventConditions, {event.name, specialState}) end table.insert(conditions, eventConditions) end local conditionCombinations = h.cartesianProduct(conditions) return conditionCombinations end function h.cartesianProduct(sets) local setA = table.remove(sets, 1) local setB = table.remove(sets, 1) local product = {} for _, a in ipairs(setA) do		if setB then for _, b in ipairs(setB) do				table.insert(product, {a, b}) end else table.insert(product, {a}) end end if #sets > 0 then table.insert(sets, product) return h.cartesianProduct(sets) else return product end end

function h.filterByPreconditions(schedule, preconditions) return utilsTable.filter(schedule, function(entry)		for i, condition in ipairs(preconditions) do			local eventName, conditionState = unpack(condition)			local eventState = entry.preconditions and entry.preconditions[eventName]			if eventState ~= conditionState and (type(conditionState) == "string" or eventState ~= nil) then				return false			end		end		return true	end) end

p.Templates = { ["MM Schedule"] = { params = { ["defaultBranch"] = { trim = true, type = "string", },			["..."] = {				name = "schedule", type = "content", trim = true, },		},	} }

return p