<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE muclient><muclient><script><![CDATA[ -- this is all on the first line (including this comment) so that lua error messages will have correct line numbers

require "json"

local SELF_ID = GetPluginID()
local GMCP_INTERFACE_ID = "c190b5fc9e83b05d8de996c3"
local winID = SELF_ID .. "RossAsciiMap"

local rossPluginsDir = GetPluginInfo(SELF_ID, 20)

local RGBToInt = dofile(rossPluginsDir .. "RGBToInt.lua")
local window = dofile(rossPluginsDir .. "window.lua")
local parseMDT = dofile(rossPluginsDir .. "MapDoorTextParser.lua")

local function copy(t, out)
	out = out or {}
	for k,v in pairs(t) do  out[k] = v  end
	return out
end

-- Settings:
--------------------------------------------------
local COLORS = {
	background = 0,
	border = 12632256,
	Normal = RGBToInt(192),
	Yellow = RGBToInt(255, 255, 0),
	Red = RGBToInt(255, 0, 0),
	Green = RGBToInt(0, 255, 0),
	Cyan = RGBToInt(0, 255, 255),
	Blue = RGBToInt(0, 0, 255),
	Magenta = RGBToInt(255, 0, 255),
	White = RGBToInt(255),
	Desert = 34815,
}
local DEFAULT_COLORS = {}
for k,v in pairs(COLORS) do  DEFAULT_COLORS[k] = v  end
local fontFamily = "fixedsys"
local fontSize = 9 -- NOTE: FixedSys only comes in this size.
local spacingX, spacingY = 0, 0

-- Other Vars:
--------------------------------------------------
local winRect = {x = 800, y = 0, width = 200, height = 200, z = 0}
local winLocked = false
local fontID = "font" -- Not the font name, just the ID it's registered to for our window.
local fontHeight
local fontCharWidth
local fontMaxCharWidth -- For the rect to draw each character within.
local loadMapFont, loadFilterGroupFont -- Upvalue for functions defined below.

local playerY, playerX = 1, 1
local mapLines = {}
local colorChangesAt = {}
local mxpColorRegex = rex.new("MXP<(.+?)MXP>")
local hexRegex = rex.new(".*? (#[0-9a-f]{6})") -- MXP<C #ff8700MXP>
local lastMapPacket, lastMDTPacket-- Save last raw packet string to disk so we can show something on load.

local MDTIndicatorsEnabled = true
local MDTPlayerPrefix = "P)"
local entityFilters = {}
local DEFAULT_FILTER_GROUP = {
	name = "default",
	color = RGBToInt(255, 0, 0),
	fontFamily = "fixedsys",
	fontSize = 9,
	ox = 2,
	oy = 0
}
local filterGroups = { default = copy(DEFAULT_FILTER_GROUP) }
local disabledGroups = {}
local CASE_INSENSITIVE = rex.flags().CASELESS

-- Loading & Saving Settings:
--------------------------------------------------
local function toboolean(v) -- Convert a value to a boolean. Always returns a boolean.
	if v == nil then  return false
	elseif v == false or v == "false" then  return false  end
	return true -- Anything other than nil, false, or "false" ==> true.
end

local function loadVar(name) -- Returns nil instead of an empty string.
	local v = GetVariable(name)
	if v == "" then  return nil  end
	return v
end

local function loadSettings()
	for k,v in pairs(winRect) do
		winRect[k] = tonumber(GetVariable("window_" .. k)) or v
	end
	winLocked = toboolean(GetVariable("windowLocked"))
	lastMapPacket = loadVar("lastMapPacket")
	lastMDTPacket = loadVar("lastMDTPacket")
	fontFamily = loadVar("fontFamily") or fontFamily
	fontSize = loadVar("fontSize") or fontSize
	spacingX = loadVar("spacingX") or spacingX
	spacingY = loadVar("spacingY") or spacingY
	for colorName in pairs(COLORS) do
		COLORS[colorName] = tonumber(loadVar("color_" .. colorName)) or DEFAULT_COLORS[colorName]
	end

	local MDTEnabled = loadVar("MDTIndicatorsEnabled")
	if MDTEnabled then  MDTIndicatorsEnabled = toboolean(MDTEnabled)  end

	MDTPlayerPrefix = loadVar("MDTPlayerPrefix") or MDTPlayerPrefix

	local filters = loadVar("entityFilters")
	if filters then
		entityFilters = json.decode(filters)
		for i,f in ipairs(entityFilters) do
			local regexFlags = f.doesIgnoreCase and CASE_INSENSITIVE or nil
			f.regex = rex.new(f.pattern, regexFlags)
		end
	end

	local groups = loadVar("filterGroups")
	if groups then  filterGroups = json.decode(groups)  end

	local disabled = loadVar("disabledGroups")
	if disabled then  disabledGroups = json.decode(disabled)  end
end

local function saveVar(name, val, nilVal)
	if val == nil then  val = nilVal or false  end
	SetVariable(name, tostring(val))
end

local function saveSettings()
	for k,v in pairs(winRect) do
		saveVar("window_" .. k, v)
	end
	winLocked = window.getLocked(winID)
	saveVar("windowLocked", winLocked)
	if lastMapPacket then  saveVar("lastMapPacket", lastMapPacket)  end
	if lastMDTPacket then  saveVar("lastMDTPacket", lastMDTPacket)  end
	saveVar("fontFamily", fontFamily, "fixedsys")
	saveVar("fontSize", fontSize, 9)
	saveVar("spacingX", spacingX, 0)
	saveVar("spacingY", spacingY, 0)
	for colorName in pairs(COLORS) do
		saveVar("color_" .. colorName, COLORS[colorName])
	end

	saveVar("MDTIndicatorsEnabled", MDTIndicatorsEnabled)

	saveVar("MDTPlayerPrefix", MDTPlayerPrefix, "")

	-- Save custom entity filters.
	if next(entityFilters) then
		-- Can't save regex object, so make a copy of the filters without it.
		local saveData = {}
		local filterPropsToSave = {"pattern", "score", "group", "keepEvaluating", "doesIgnoreCase"}
		for i,f in ipairs(entityFilters) do
			local v = {}
			for _,prop in ipairs(filterPropsToSave) do  v[prop] = f[prop]  end
			saveData[i] = v
		end
		saveVar("entityFilters", json.encode(saveData))
	end

	saveVar("filterGroups", json.encode(filterGroups))
	saveVar("disabledGroups", json.encode(disabledGroups))
end

function OnPluginSaveState()
	saveSettings()
end

local function refresh()
	if lastMapPacket then
		onGMCPReceived("room.map", lastMapPacket)
	end
	if lastMDTPacket and MDTIndicatorsEnabled then
		onGMCPReceived("room.writtenmap", lastMDTPacket)
	else
		window.draw(winID)
	end
end

-- Living Thing Filter Editing:
--------------------------------------------------
local curMDTData = nil
local getCountRegex = rex.new("^(\\d+)")

local function setPlayerPrefix()
	local msg = [[The text you enter will be inserted before each player name so you can have a custom filter for them.
If you put in something weird like " and ", "four ", or "one northwest ", then you'll break things.]]
	local prefix = utils.inputbox(msg, "Select Player Name Prefix", MDTPlayerPrefix)
	if prefix then
		MDTPlayerPrefix = prefix
		window.setMenuItem(winID, 34, "Set player name prefix...(cur: "..MDTPlayerPrefix..")")
		refresh()
	end
end

local function addFilter(pattern, score, group, keepEvaluating, doesIgnoreCase)
	local regexFlags = doesIgnoreCase and CASE_INSENSITIVE or nil
	local filter = {
		regex = rex.new(pattern, regexFlags),
		pattern = pattern,
		score = score,
		group = group or "default",
		keepEvaluating = keepEvaluating,
		doesIgnoreCase = doesIgnoreCase
	}
	table.insert(entityFilters, filter)
end

local function setFilter(i, filter)
	-- 1. Set the regex pattern.
	local msg = [[Enter a regular expression to check against the living thing name.

If there are multiple identical things in the room, their text will start with a number.
i.e. "2 annoying children".
Capitalization is preserved, but you can set the regex to be case-insensitive.]]
	local extras = { box_width = 800, box_height = 250, prompt_height = 100 }
	local default = filter and filter.pattern
	local pattern = utils.editbox(msg, "Define RegEx", default, nil, nil, extras)
	if not pattern then  return  end

	-- 2. Set the score.
	local msg = [[Enter your desired score number.]]
	local default = filter and filter.score or 1
	local score = utils.inputbox(msg, "Select Score", default)
	if not score then  return  end
	score = tonumber(score)
	if not score then  Note("[RossAsciiMap] - Invalid score. Must be a number. Aborting.")  return  end

	-- 3. Set the group.
	local msg = [[Things in each group is counted separately and each group count can be drawn as a separate indicator.]]
	local default = filter and filter.group or "default"
	local list = {}
	for k,v in pairs(filterGroups) do  list[k] = k  end
	local group = utils.choose(msg, "Select Group", list, default)
	if not group then  return  end

	-- 4. Set 'keepEvaluating'.
	local msg = [[Should we keep evaluating if this filter matches?
i.e. should subsequent filters be able to override this one?]]
	local default = filter and (filter.keepEvaluating and 1 or 2) or 2
	local keepEvaluating = utils.msgbox(msg, "Keep Evaluating?", "yesno", "?", default)
	if not keepEvaluating then  return  end
	keepEvaluating = keepEvaluating == "yes" and true or nil

	-- 5. Set 'doesIgnoreCase'
	local msg = [[Is the regex pattern case-sensitive?]]
	local default = filter and (filter.doesIgnoreCase and 2 or 1) or 1
	local doesIgnoreCase = utils.msgbox(msg, "Case-sensitive?", "yesno", "?", 1)
	if not doesIgnoreCase then  return  end
	doesIgnoreCase = doesIgnoreCase == "no" and true or nil

	if filter then
		local regexFlags = doesIgnoreCase and CASE_INSENSITIVE or nil
		filter.regex = rex.new(pattern, regexFlags)
		filter.pattern = pattern
		filter.score = score
		filter.group = group
		filter.keepEvaluating = keepEvaluating
		filter.doesIgnoreCase = doesIgnoreCase
	else
		addFilter(pattern, score, group, keepEvaluating, doesIgnoreCase)
	end
	refresh()
end

local function chooseFilter(msg, title, isMulti)
	local list = {}
	for i,f in ipairs(entityFilters) do
		local str = '%s: %s, %s, "%s", %s, %s  %s'
		local isDisabled = disabledGroups[f.group or "default"] and "(group disabled)" or ""
		str = str:format(
			i, f.pattern, f.score, f.group or "default",
			tostring(not not keepEvaluating), tostring(not doesIgnoreCase),
			isDisabled
		)
		table.insert(list, str)
	end

	local message = 'Shown as: #: regex pattern, score, "group", keepEvaluating, caseSensitive'
	if msg then
		message = message .. "\n\n" .. msg
	end
	local fn = isMulti and utils.multilistbox or utils.listbox
	return fn(message, title, list), list -- Return the filter index.
end

local function modifyFilter()
	local filterIdx = chooseFilter(nil, "Choose a Filter to Modify")
	if filterIdx then
		setFilter(nil, entityFilters[filterIdx])
	end
end

local function deleteFilters()
	local filterIndices, list = chooseFilter("You may select multiple.", "Choose Filters to Delete", true)
	if filterIndices then
		for filterIdx in pairs(filterIndices) do
			local idStr = list[filterIdx]
			local msg = "Are you sure you want to delete the filter:\n" .. idStr
			local confirm = utils.msgbox(msg, "Really Delete?", "okcancel")
			if confirm == "ok" then
				table.remove(entityFilters, filterIdx)
			end
		end
		refresh()
	end
end

local function setFilterOrder()
	local filterIdx = chooseFilter(nil, "Choose a Filter to Reorder")
	if filterIdx then
		local minI, maxI = 1, #entityFilters
		local list = {}
		for i=minI,maxI do  list[i] = i  end
		local toIdx = utils.choose("", "Choose a New Index", list, filterIdx)
		if toIdx then
			local filter = entityFilters[filterIdx]
			table.remove(entityFilters, filterIdx)
			table.insert(entityFilters, toIdx, filter)
			refresh()
		end
	end
end

-- Group Editing:
--------------------------------------------------
local function addGroup(name, color, fontFamily, fontSize, ox, oy)
	local group = {
		name = name,
		color = color,
		fontFamily = fontFamily,
		fontSize = fontSize,
		ox = ox,
		oy = oy
	}
	filterGroups[name] = group
	loadFilterGroupFont(winID, group)
end

local function setGroup(i, group)
	-- 1. Set the group name.
	local msg = [[Enter the group name.]]
	local default = group and group.name
	local name = utils.inputbox(msg, "Enter a Group Name", default)
	if not name then  return  end
	if filterGroups[name] and not (group and group.name == name) then
		Note('[RossAsciiMap] - The group name: "'..name..'" is already used. Aborting.')
		return
	end

	-- 2. Set the group color.
	local default = group and group.color or DEFAULT_FILTER_GROUP.color
	local color = PickColour(default)
	if color == -1 then  return  end

	-- 3. Set the group font family and size.
	local defaultFamily = group and group.fontFamily or DEFAULT_FILTER_GROUP.fontFamily
	local defaultSize = group and group.fontSize or DEFAULT_FILTER_GROUP.fontSize
	local fontSpecs = utils.fontpicker(defaultFamily, defaultSize)
	local fontFamily, fontSize = defaultFamily, defaultSize
	if fontSpecs then
		fontFamily, fontSize = fontSpecs.name, fontSpecs.size
	end

	-- 4. Set the group X-offset.
	local default = group and group.ox or DEFAULT_FILTER_GROUP.ox
	local title = "Choose X Offset"
	local msg = "Enter the offset on the X-axis where the group score will be shown."
	local ox = utils.inputbox(msg, title, default)
	if not ox then  return  end
	ox = tonumber(ox)
	if not ox then  Note("[RossAsciiMap] - Invalid offset. Must be a number. Aborting.")  return  end

	-- 5. Set the group Y-offset.
	local default = group and group.oy or DEFAULT_FILTER_GROUP.oy
	local title = "Choose Y Offset"
	local msg = "Enter the offset on the Y-axis where the group score will be shown."
	local oy = utils.inputbox(msg, title, default)
	if not oy then  return  end
	oy = tonumber(oy)
	if not oy then  Note("[RossAsciiMap] - Invalid offset. Must be a number. Aborting.")  return  end

	if group then
		group.name = name
		group.color = color
		group.fontFamily = fontFamily
		group.fontSize = fontSize
		group.ox = ox
		group.oy = oy
		loadFilterGroupFont(winID, group)
	else
		addGroup(name, color, fontFamily, fontSize, ox, oy)
	end
	refresh()
end

local function chooseGroup(msg, title, isMulti)
	local list = {}
	for groupName,group in pairs(filterGroups) do
		local str = '"%s", %s, %s, %s, %s, %s  %s'
		local isDisabled = disabledGroups[groupName] and "(disabled)" or ""
		str = str:format(groupName, group.color, group.fontFamily, group.fontSize, group.ox, group.oy, isDisabled)
		list[groupName] = str
	end

	local message = 'Shown as: "groupName", color, font, fontSize, offsetX, offsetY'
	if msg then
		message = message .. "\n\n" .. msg
	end
	local fn = isMulti and utils.multilistbox or utils.listbox
	return fn(message, title, list), list -- Returns the group name.
end

local function modifyGroup()
	local groupName = chooseGroup("Select a group to modify it.", "Choose a Group to Modify")
	if groupName then
		setGroup(nil, filterGroups[groupName])
	end
end

local function deleteGroups()
	local groupNames, list = chooseGroup("You may select multiple.", "Choose Groups to Delete", true)
	if groupNames then
		for groupName in pairs(groupNames) do
			local idStr = list[groupName]
			local msg = "Are you sure you want to delete the group:\n" .. idStr
			local confirm = utils.msgbox(msg, "Really Delete?", "okcancel")
			if confirm == "ok" then
				filterGroups[groupName] = nil
			end
		end
		refresh()
	end
end

local function toggleGroups()
	local msg = "You may select multiple."
	msg = msg .. "\nAny filters associated with disabled groups will be skipped."
	local groupNames, list = chooseGroup(msg, "Choose Groups to Toggle On/Off", true)
	if groupNames then
		for groupName in pairs(groupNames) do
			if disabledGroups[groupName] then
				disabledGroups[groupName] = nil -- Enable again, remove from dict.
			else
				disabledGroups[groupName] = true -- Disable.
			end
		end
		refresh()
	end
end

function aliasSetGroupsEnabled(name, line, captures)
	local isDisable = captures[1] == "disable"
	local groupList = captures[2]
	groupList:gsub(",", "")
	local groupNames = {}
	for groupName in string.gmatch(groupList, "([^%s]+)") do
		table.insert(groupNames, groupName)
	end
	local val = isDisable or nil
	local msg = "[RossAsciiMap] - " .. (isDisable and "Disabling" or "Enabling") .. ' group: "'
	for i,groupName in ipairs(groupNames) do
		if not filterGroups[groupName] then
			Note('[RossAsciiMap] - Group: "' .. groupName .. '" does not exist.')
		else
			disabledGroups[groupName] = val
			Note(msg .. groupName .. '".')
		end
	end
	refresh()
end

-- MDT Data Handling:
--------------------------------------------------
local function printLastMDTPacket()
	if lastMDTPacket then  print(lastMDTPacket)  end
end

local function toggleMDTIndicators(i)
	MDTIndicatorsEnabled = not MDTIndicatorsEnabled
	window.checkMenuItem(winID, i, MDTIndicatorsEnabled)
	refresh()
end

-- Loop through each room with things in it and give it a score property.
local function scoreMDTRooms(rooms)
	for i,room in ipairs(rooms) do
		local scoreList = {}
		for i,entityStr in ipairs(room.entities) do
			local _, _, capt = getCountRegex:match(entityStr)
			local count = capt and capt[1] or 1
			local wasCaughtByFilter = false
			for i,filter in ipairs(entityFilters) do
				if not disabledGroups[filter.group] then
					if filter.regex:match(entityStr) then
						wasCaughtByFilter = true
						local groupName = filter.group or "default"
						scoreList[groupName] = scoreList[groupName] or 0
						scoreList[groupName] = scoreList[groupName] + count * filter.score
						if not filter.keepEvaluating then  break  end
					end
				end
			end
			if not wasCaughtByFilter then
				scoreList.default = scoreList.default or 0
				scoreList.default = scoreList.default + count
			end
		end
		room.scoreList = scoreList
	end
end

-- Make a 2D array of room scores with relative coordinates from player.
local function makeMDTArraymap(data)
	local map = {}
	for i,room in ipairs(data) do
		local x, y = room.dx, room.dy
		map[y] = map[y] or {}
		map[y][x] = room.scoreList
	end
	data.scoreMap = map
end

-- Right-Click Menu Handling:
--------------------------------------------------
local function pickFont()
	local fnt = utils.fontpicker(fontFamily, fontSize)
	if fnt then
		fontFamily, fontSize = fnt.name, fnt.size
		loadMapFont(winID, fontID, fontFamily, fontSize)
		window.draw(winID)
	end
end

local function pickColor(i, colorName)
	local result = PickColour(COLORS[colorName])
	if result ~= -1 then
		COLORS[colorName] = result
		refresh()
	end
end

local function resetColorToDefault(i, colorName)
	COLORS[colorName] = DEFAULT_COLORS[colorName]
	refresh()
end

local function setSpacing(i, axis)
	local curSpacing = axis == "X" and spacingX or spacingY
	local msg = "Enter your desired " .. axis .. " spacing."
	local title = "Set " .. axis .. " Spacing"
	local newSpacing = utils.inputbox(msg, title, curSpacing)
	if not newSpacing then  return  end
	newSpacing = tonumber(newSpacing)
	if not newSpacing then  Note("[RossAsciiMap] - Invalid spacing. Must be a number. Aborting.")  return  end
	if axis == "X" then
		spacingX = newSpacing
		window.setMenuItem(winID, 2, "Set X spacing...(cur: "..spacingX..")")
	else
		spacingY = newSpacing
		window.setMenuItem(winID, 3, "Set Y spacing...(cur: "..spacingY..")")
	end
	refresh()
end

local menuItems = {} -- List of tables with button text and callback arguments.
local menuItemTextList = {} -- List of button texts for adding to window

local function menuItem(text, fn, ...)
	table.insert(menuItems, {text, fn, ...})
end

local function setupMenuItems()
	menuItems, menuItemTextList = {}, {}

	menuItem("Set font...", pickFont)
	menuItem("Set X spacing...(cur: "..spacingX..")", setSpacing, "X")
	menuItem("Set Y spacing...(cur: "..spacingY..")", setSpacing, "Y")
	local colorNames = {"background", "border", "Normal", "Yellow", "Red", "Green", "Cyan", "Blue", "Desert", "Magenta", "White"}
	menuItem(">Colors:")
	menuItem(">Defaults:")
	for i,colorName in ipairs(colorNames) do
		menuItem("Reset "..colorName.." to default", resetColorToDefault, colorName)
	end
	menuItem("<")
	menuItem("-")
	for i,colorName in ipairs(colorNames) do
		menuItem("Set "..colorName.." color...", pickColor, colorName)
	end
	menuItem("<")
	menuItem(">Living thing indicators:")
	local checkChar = MDTIndicatorsEnabled and "+" or ""
	menuItem(checkChar .. "Enabled", toggleMDTIndicators)
	menuItem("Output last MDT packet (for debug)", printLastMDTPacket)
	menuItem("Set player name prefix...(cur: "..MDTPlayerPrefix..")", setPlayerPrefix)
	menuItem("-")
	menuItem("Show filters...", modifyFilter)
	menuItem("Add filter...", setFilter)
	menuItem("Delete filters...", deleteFilters)
	menuItem("Change filter order...", setFilterOrder)
	menuItem("-")
	menuItem("Show groups...", modifyGroup)
	menuItem("Add group...", setGroup)
	menuItem("Delete groups...", deleteGroups)
	menuItem("Toggle groups...", toggleGroups)
	menuItem("<")

	for i,v in ipairs(menuItems) do  table.insert(menuItemTextList, v[1])  end
end

local function menuItemClicked(winID, i, prefix, item)
	local data = menuItems[i]
	if data then
		local fn = data[2]
		fn(i, unpack(data, 3))
	end
end

-- GMCP Parsing:
--------------------------------------------------
local function processColorCodes(startPos, line, lineIdx)
	-- Strip out color codes and record at which character index the color changes.
	local startI, endI, captures = mxpColorRegex:match(line, startPos)
	if endI then
		local colorName = captures[1]

		if colorName == "Yellow" then -- This is the line the player is on.
			playerX, playerY = startI, lineIdx
		end

		-- Convert color string into an integer RGB value.
		local colorInt
		if COLORS[colorName] then
			colorInt = COLORS[colorName]
		else
			local _s, _e, hexCaptures = hexRegex:match(colorName)
			if hexCaptures then
				if hexCaptures[1] == "#ff8700" then
					colorName = "Desert"
					colorInt = COLORS[colorName]
				else
					colorInt = ColourNameToRGB(hexCaptures[1]) -- ColourNameToRGB will convert hex colors.
				end
			else
				Note("[RossAsciiMap] - Unrecognized color code: '", string.sub(line, startI, endI), "'.")
				colorInt = COLORS.Normal
			end
		end
		colorChangesAt[lineIdx] = colorChangesAt[lineIdx] or {}
		colorChangesAt[lineIdx][startI] = colorInt

		-- Cut color code chunk out of map string.
		local pre = string.sub(line, 1, startI - 1)
		local post = string.sub(line, endI + 1)
		line = pre .. post
	end
	-- We're removing everything from startI to endI, so our startI should
	-- also be the start for the next match.
	return startI, line
end

--[[
-- Output from MUD:
"\u001b[3z\u001b[4zMXP<RedMXP>+\u001b[3z     \u001b[3z\u001b[4zMXP<RedMXP>+\u001b[3z   \n\u001b[3z\u001b[4zMXP<GreenMXP>&\u001b[3z-\u001b[3z\u001b[4zMXP<CyanMXP>*\u001b[3z-\u001b[3z\u001b[4zMXP<YellowMXP>@\u001b[3z-\u001b[3z\u001b[4zMXP<GreenMXP>$\u001b[3z   \n       \u001b[3z\\\u001b[3z  \n        \u001b[3z\u001b[4zMXP<CyanMXP>*\u001b[3z-\u001b[3z\n"
-- Desired Result:
+     +
&-*-@-$
       \
        *-
--]]

-- example: regexSplit("a,b, c", ", ?")  =>  {"a", "b", "c"}
function regexSplit(text, regularExpression)
	local ret = {}
	local reg = rex.new(regularExpression)
	local matchStart, matchEnd = reg:match(text)
	if matchStart == nil then
		table.insert(ret, text)
	else
		local prevEnd = 0
		while matchStart ~= nil do
			table.insert(ret, string.sub(text, prevEnd + 1, matchStart - 1))
			prevEnd = matchEnd
			matchStart, matchEnd = reg:match(text, prevEnd + 1)
		end
		table.insert(ret, string.sub(text, prevEnd + 1, -1))
	end
	return ret
end

-- cb is passed the MDTData and should return true if the display should be updated
function editMDTData(cb)
	if MDTIndicatorsEnabled and curMDTData ~= nil then
		local changed = cb(curMDTData)
		if changed then
			scoreMDTRooms(curMDTData)
			makeMDTArraymap(curMDTData)
			window.draw(winID) -- writtenmap is sent second, so draw here.
		end
	end
end

local numStrToNum = { one = 1, two = 2, three = 3, four = 4, five = 5, six = 6, seven = 7, eight = 8, nine = 9, ten = 10, eleven = 11, twelve = 12, thirteen = 13, fourteen = 14 }

function parseNumNpc(str)
	local count, name
	local i = string.find(str, ' ')
	if i then
		local firstWord = string.sub(str, 1, i - 1)
		name = string.sub(str, i + 1)
		count = numStrToNum[string.lower(firstWord)]
		if not count then
			count = tonumber(firstWord)
			if not count then
				count = 1
				name = firstWord .. ' ' .. name
			end
		end
	else
		count = 1
		name = str
	end
	return count, name
end

function formatNumNpc(count, str)
	if count == 1 then
		return str
	else
		return count .. ' ' .. str
	end
end

function invertKV(kv)
	local ret = {}
	for k, v in pairs(kv) do
		ret[v] = k
	end
	return ret
end

local pluralizeCache = {
	man = "men",
	fisherman = "fishermen",
	woman = "women",
	mercenary = "mercenaries",
	lady = "ladies",
	sheep = "sheep",
	grflx = "grflxen",
	child = "children",
}
local singularizeCache = invertKV(pluralizeCache)

function autopluralize(str)
	if str:match('[sxz]$') or str:match('[cs]h$') then
		return str .. 'es'
	elseif str:match('quy$') or str:match('[^aeiou]y$') then
		return str .. 'ies'
	else
		return str .. 's'
	end
end

function pluralizeWord(word)
	if not pluralizeCache[word] then
		local plural = autopluralize(word)
		pluralizeCache[word] = plural
		singularizeCache[plural] = word
	end
	return pluralizeCache[word]
end

function pluralize(str)
	local words = regexSplit(str, ' ')
	words[#words] = pluralizeWord(words[#words])
	return table.concat(words, ' ')
end

function singularizeWord(word)
	return singularizeCache[word] or string.sub(word, 1, -2)
end

-- only called when if there are 3 or more and all but 1 enter at once
function singularize(str)
	local words = regexSplit(str, ' ')
	words[#words] = singularizeWord(words[#words])
	return table.concat(words, ' ')
end

local exitToMoves = { northeast = "1 ne", northwest = "1 nw", southeast = "1 se", southwest = "1 sw", north = "1 n", south = "1 s", east = "1 e", west = "1 w" }

function triggerNPCEntered(name, line, wildcards)
	local allEntering = wildcards.npcs
	local moves = exitToMoves[wildcards.exit]
	editMDTData(function (data)
		local changed = false
		for roomI,room in ipairs(data) do
			if room.entities ~= nil then
				if #room.moves == 1 and room.moves[1] == moves then
					for _, numNpc in ipairs(regexSplit(allEntering, "(, | and (?!yellow |white ))(an? )?")) do
						local enteringCount, enteringName = parseNumNpc(numNpc)
						local enteringPlural = enteringCount > 1
						for distantI, distant in ipairs(room.entities) do
							local distantCount, distantName = parseNumNpc(distant)
							local distantPlural = distantCount > 1
							local maybePluralEnteringName = enteringName
							if distantPlural and not enteringPlural then
								maybePluralEnteringName = pluralize(enteringName)
							end
							if maybePluralEnteringName == distantName then
								local remaining = distantCount - enteringCount
								if remaining < 1 then
									table.remove(room.entities, distantI)
								else
									if remaining == 1 then
										if enteringCount == 1 then
											distantName = enteringName
										else
											distantName = singularize(enteringName)
										end
									end
									room.entities[distantI] = formatNumNpc(remaining, distantName)
								end
								changed = true
								break
							end
						end
					end
				end
			end
		end
		return changed
	end)
end

function onGMCPReceived(message, dataStr)
	if message == "room.writtenmap" then
		lastMDTPacket = dataStr
		if MDTIndicatorsEnabled then
			curMDTData = parseMDT(dataStr, nil, MDTPlayerPrefix) -- Gets a list of rooms with things in them.
			scoreMDTRooms(curMDTData)
			makeMDTArraymap(curMDTData)
		end
		window.draw(winID) -- writtenmap is sent second, so draw here.
	elseif message == "room.map" then
		-- Map is colored with MXP color codes. First strip out the extra junk, then split into lines and process the color codes.
		lastMapPacket = dataStr
		local map = dataStr
		map = string.sub(map, 2, -2) -- remove surrounding quotes ("").
		map = string.gsub(map, "\\u001b%[4z", "") -- Remove ANSI codes before MXP colors.
		map = string.gsub(map, "MXP<[\\/]-send.-MXP>", "") -- remove MXP links, if any (not colors).
		-- Replace ANSI reset code with a fake MXP color tag, which the next step will pick up.
		map = string.gsub(map, "\\u001b%[3z", "MXP<NormalMXP>")
		-- Un-escape actual map characters.
		map = string.gsub(map, "\\\\", "\\") -- change \\ to \.
		map = string.gsub(map, "\\\/", "\/") -- change \/ to /.

		for i=#mapLines,1,-1 do -- Clear old lists.
			mapLines[i] = nil
			colorChangesAt[i] = nil
		end

		for line in string.gmatch(map, "(.-)\\n") do -- Split map up into lines.
			table.insert(mapLines, line)
		end

		for lineIdx,line in ipairs(mapLines) do
			local startPos = 1
			while startPos do
				startPos, line = processColorCodes(startPos, line, lineIdx)
			end
			mapLines[lineIdx] = line -- Update line after stripping out color codes.
		end
	end
end

-- Drawing:
--------------------------------------------------
local function drawWindow()
	winRect.width = WindowInfo(winID, 3)
	winRect.height = WindowInfo(winID, 4)
	WindowRectOp(winID, 2, 0, 0, winRect.width, winRect.height, COLORS.background) -- Clear/Fill Background
	WindowRectOp(winID, 1, 0, 0, winRect.width, winRect.height, COLORS.border) -- Draw Border

	local gridX, gridY = fontCharWidth + spacingX, fontHeight + spacingY

	local mapHalfWidth = (playerX - 0.5) * gridX
	local mapHalfHeight = (playerY - 0.5) * gridY

	local ox, oy = winRect.width/2 - mapHalfWidth, winRect.height/2 - mapHalfHeight

	local baseCol = COLORS.Normal
	local color = baseCol
	for lineIdx,line in ipairs(mapLines) do
		local x, y = ox, oy + (lineIdx - 1)*gridY
		color = baseCol

		for charIdx=1,string.len(line) do
			if colorChangesAt[lineIdx] and colorChangesAt[lineIdx][charIdx] then
				color = colorChangesAt[lineIdx][charIdx]
				if type(color) ~= "number" then
					Note("[RossAsciiMap] - Weird color value in colorChangesAt: ", colorChangesAt[lineIdx][charIdx])
					color = COLORS.Normal
				end
			end
			local char = string.sub(line, charIdx, charIdx)
			if char ~= " " then
				WindowText(winID, fontID, char, x, y, x+fontMaxCharWidth, y+gridY, color)
			end
			x = x + gridX
		end
	end

	-- Draw room scores.
	if MDTIndicatorsEnabled and curMDTData then
		local ox, oy = winRect.width/2, winRect.height/2 -- Start from player pos, center of window.
		for dy,row in pairs(curMDTData.scoreMap) do
			for dx,scoreList in pairs(row) do
				-- Double dx and dy because rooms are every-other line on the ASCII map.
				local x, y = ox + dx*2*gridX, oy - dy*2*gridY -- Subtract dy because we draw in +y = down coordinates.
				for groupName,score in pairs(scoreList) do
					if not disabledGroups[groupName] then -- Only really affects the default group, other disabled groups are simply not scored.
						local group = filterGroups[groupName]
						if not group then
							Note('[RossAsciiMap] - No filter group defined for name: "' .. groupName .. '"')
							group = filterGroups.default or DEFAULT_FILTER_GROUP
						end
						local _x, _y = x + group.ox, y + group.oy
						local width = group.fontMaxCharWidth * tostring(score):len()
						WindowText(winID, group.fontID, score, _x, _y, _x+width, _y+gridY, group.color)
					end
				end
			end
		end
	end
end

-- Tooltip - Show Things Under Cursor:
--------------------------------------------------
local floor = math.floor

local function round(x, incr)
	if incr then
		return floor(x/incr + 0.5)*incr
	end
	return floor(x + 0.5)
end

local function windowMousePosToRoomPos(mx, my)
	local gridX, gridY = fontCharWidth + spacingX, fontHeight + spacingY
	local x, y = mx - winRect.width/2, my - winRect.height/2
	return round(x/gridX/2), -round(y/gridY/2)
end

local function getThingsInRoom(rx, ry)
	if not curMDTData then  return  end

	for i,room in ipairs(curMDTData) do
		if room.dx == rx and room.dy == ry then
			return room.entities
		end
	end
end

local tooltipRX, tooltipRY, tooltipText
local tooltipWinID = SELF_ID .. "tooltip"
local tooltipFont, tooltipFontSize = "FixedSys", 9
local tooltipFontHeight
local padding = 2
local tooltipBorderColor = RGBToInt(80)
local tooltipTextColor = RGBToInt(160)
local tooltipIsVisible = false
local tooltipW, tooltipH

local function drawTooltip()
	WindowRectOp(tooltipWinID, 2, 0, 0, tooltipW, tooltipH, 0) -- Draw background
	WindowRectOp(tooltipWinID, 1, 0, 0, tooltipW, tooltipH, tooltipBorderColor) -- Draw border
	WindowText(tooltipWinID, "font", tooltipText, padding, padding, tooltipW, tooltipH, tooltipTextColor)
	Redraw()
end

local function showTooltip(x, y, rx, ry, text)
	tooltipIsVisible = true
	tooltipRX, tooltipRY, tooltipText = rx, ry, text
	tooltipW = WindowTextWidth(tooltipWinID, "font", text) + padding*2
	tooltipH = tooltipFontHeight + padding*2
	WindowResize(tooltipWinID, tooltipW, tooltipH, 0)
	local gridX, gridY = fontCharWidth + spacingX, fontHeight + spacingY
	local winX, winY = WindowInfo(winID, 1), WindowInfo(winID, 2)
	local x = winX + winRect.width/2 + rx*2 * gridX
	local y = winY + winRect.height/2 - ry*2 * gridY
	WindowPosition(tooltipWinID, x, y - tooltipH*1.5, 5, 2)

	WindowShow(tooltipWinID, true)
	local mapWindowZ = WindowInfo(winID, 22)
	WindowSetZOrder(tooltipWinID, mapWindowZ + 1) -- NOTE: winRect.z is only set when settings ore loaded or saved.

	drawTooltip()
end

local function hideTooltip()
	tooltipIsVisible = false
	WindowShow(tooltipWinID, false)
	tooltipRX, tooltipRY, tooltipText = nil, nil, nil
end

function OnPluginMouseMoved(x, y, mw)
	if mw == winID then
		local rx, ry = windowMousePosToRoomPos(WindowInfo(winID, 14), WindowInfo(winID, 15))

		local thingList = getThingsInRoom(rx, ry)
		local text
		if thingList and #thingList > 0 then
			text = table.concat(thingList, ", ")
		end

		if tooltipIsVisible and not text then
			hideTooltip()
		elseif text then
			showTooltip(x, y, rx, ry, text)
		end
	else
		if tooltipIsVisible then  hideTooltip()  end
	end
end

-- Setup & Breakdown:
--------------------------------------------------
function loadMapFont(winID, fontID, fontFamily, fontSize) -- Is local--upvalue defined above.
	WindowFont(winID, fontID, fontFamily, fontSize, nil, nil, nil, nil, 0)
	fontHeight = WindowFontInfo(winID, fontID, 1)
	fontCharWidth = WindowFontInfo(winID, fontID, 6) -- Average character width.
	fontMaxCharWidth = WindowFontInfo(winID, fontID, 7) -- Max character width.
end

function loadFilterGroupFont(winID, group) -- Is local--upvalue defined above.
	group.fontID = "font_" .. group.name
	group.fontFamily = group.fontFamily or "FixedSys"
	group.fontSize = group.fontSize or 9
	WindowFont(winID, group.fontID, group.fontFamily, group.fontSize)
	group.fontMaxCharWidth = WindowFontInfo(winID, group.fontID, 7) -- Max character width.
end

local function init()
	CallPlugin(GMCP_INTERFACE_ID, "subscribe", SELF_ID, "onGMCPReceived", "room.map", "room.writtenmap")
	loadSettings()
	window.new(
		winID, winRect.x, winRect.y, winRect.width, winRect.height, winRect.z,
		nil, nil, nil, true, winLocked, menuItemClicked, drawWindow
	)
	setupMenuItems()
	window.addMenuItems(winID, 1, menuItemTextList)
	loadMapFont(winID, fontID, fontFamily, fontSize)
	loadFilterGroupFont(winID, DEFAULT_FILTER_GROUP)
	for groupName,group in pairs(filterGroups) do
		loadFilterGroupFont(winID, group)
	end
	refresh()

	-- Tooltip.
	WindowCreate(tooltipWinID, 0, 0, 100, 25, 5, 2, 0)
	WindowFont(tooltipWinID, "font", tooltipFont, tooltipFontSize)
	tooltipFontHeight = WindowFontInfo(tooltipWinID, "font", 1)
end

local function final()
	winRect.x, winRect.y, winRect.width, winRect.height, winRect.z = window.getRect(winID)
	winLocked = window.getLocked(winID)
	WindowDelete(winID)
	WindowDelete(tooltipWinID)
	CallPlugin(GMCP_INTERFACE_ID, "unsubscribe", SELF_ID, "room.map", "room.writtenmap")
end

function OnPluginInstall()  init()  end
function OnPluginEnable()  init()  end
function OnPluginClose()  final()  end
function OnPluginDisable()  final() end

	]]>
</script>


<plugin
	 name="RossAsciiMap"
	 author="Ross Grams"
	 purpose="ASCII-map miniwindow"
	 id="4045bd321bb34f7bc51a3ce8"
	 language="Lua"
	 save_state="y"
	 date_written="2019-02-22 13:38:17"
	 requires="5.05"
	 version="2.0"
	 >
</plugin>

<aliases>
	<alias
		match="^asciimap (enable|disable) (.+)$"
		enabled="y"
		regexp="y"
		sequence="100"
		script="aliasSetGroupsEnabled"
		>
	</alias>
</aliases>

<triggers>
	<trigger
		match="^asciimap (enable|disable) (.+)$"
		enabled="y"
		regexp="y"
		sequence="100"
		script="aliasSetGroupsEnabled"
		>
	</trigger>
</triggers>

<triggers>
    <trigger
		script="triggerNPCEntered"
        enabled="y"
        ignore_case="n"
        keep_evaluating="y"
        match="^(\*thump\* \*click\* \*thump\* \*click\* )?([aA] |[aA]n |[tT]he )?(?P<npcs>.*) (arrives?|climbs?|enters?|hobbles?|rides?|shambles?|shuffles?|squeezes?|swims?|trudges?|trundles?|wades?) (.* )?from (the )+(?P<exit>north|northeast|east|southeast|south|southwest|west|northwest)[.]$"
        regexp="y"
        repeat="n"
        sequence="100"
    ></trigger>
</triggers>
</muclient>