<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE muclient>

<!DOCTYPE muclient [
   <!ENTITY show_vnums "true" >
   <!ENTITY show_timing "false" >
   <!ENTITY show_completed "true" >
   <!ENTITY show_database_mods "false" >
   <!ENTITY show_other_areas "false" >
   <!ENTITY speedwalk_prefix "speedwalk" >
]>

<muclient>
<plugin
   name="MIM_GMCP_Mapper"
   author="Murderous Intentions"
   id="e7c41f0a9b3d5e2c8f6a1b4d"
   language="Lua"
   purpose="GMCP map builder for Murderous Intentions"
   date_written="2026-10-17 12:00:00"
   requires="4.73"
   version="1.0"
   save_state="y"
   >

<description trim="y">
<![CDATA[
MURDEROUS INTENTIONS AUTOMATIC MAPPER

Draws a map of the rooms you've been through, from the Room.Info data the
game sends, keeps it in a database, and finds paths and speedwalks to
rooms, shops, trainers, healers, questmasters and your own notes.

It needs two things in the same directory as this plugin:
  mim_mapper.lua          the map drawing and pathfinding library
and the MIM_GMCP_handler plugin installed in the same world.

Type 'mapper help' for the commands.

Ported from Fiendish's Aardwolf GMCP mapper:
** This is a very improved GMCP version of the original ATCP mapper by Nick Gammon.
** Some GMCP specific code added by Lasher.
** A few features contributed by Spartacus.
** Many major improvements made to the original design by Fiendish.
]]>
</description>
</plugin>

<!--  Timers  -->

<timers>
<timer
   name="backup_timer"
   enabled="y"
   script="automatic_backup"
   hour="23" minute="59" second="59"
   active_closed="y" >
</timer>
</timers>

<aliases>
<alias
   match="mapper database"
   enabled="y"
   script="get_database"
   sequence="100"
></alias>

<alias
   match="mapper set database *"
   enabled="y"
   sequence="100"
   send_to="12"
>
<send>
   new_database = Trim("%1")
   if new_database == "" then
      get_database()
   else
      Note("Are you sure you want to change your map database to "..new_database:gsub("%.db$", "", 1)..".db? To confirm type 'mapper set database confirm'.")
      toConfirm = "set database"
      EnableAlias("confirm_catch", true)
   end
</send>
</alias>

<alias
   match="mapper showroom *"
   enabled="y"
   script="draw_other"
   sequence="100"
></alias>

<alias
   match="mapper backup"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   script="manual_backup"
></alias>

<alias
   match="mapper backups*"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   send_to="12"
>
<send>
   if "%1" == " off" then
      SetVariable("backup_timer_enabled", "0")
      toggleBackups(false)
   elseif "%1" == " on" then
      SetVariable("backup_timer_enabled", "1")
      toggleBackups(true)
   elseif "%1" == "" then
      Note("Automated map database backup is currently "..((GetTimerInfo("backup_timer", 6) and "on") or ("off"..(GetVariable("backup_timer_enabled")=="1" and " but it will turn on as soon as you connect to the game" or "")))..".")
      local will_be_would_be = ((GetTimerInfo("backup_timer", 6) and "will be") or "would be (if enabled)")
      local seconds = GetTimerInfo("backup_timer", 13)
      local hours = math.floor(seconds/3600)
      seconds = seconds - hours*3600
      local minutes = math.floor(seconds/60)
      seconds = math.floor(seconds - minutes*60)
      Note("Next backup "..will_be_would_be.." in "..hours.." hours, "..minutes.." minutes, "..seconds.." seconds.")
      Note("Backup compression is currently "..(((use_compression == 1) and "enabled.") or "disabled."))
   elseif "%1" == " compressed" then
      use_compression = 1
      SetVariable("use_compression", use_compression)
      Note("Mapper backups will be compressed. This will make backups take a few seconds longer but save a bit of space.")
   elseif "%1" == " uncompressed" then
      use_compression = 0
      SetVariable("use_compression", use_compression)
      Note("Mapper backups will not be compressed. This will make backups faster but take up a bit more space.")
   elseif "%1" == " quiet" then
      quiet_backups = not quiet_backups
      SetVariable("quiet_backups", quiet_backups and "1" or "0")
      Note("Mapper automatic backups will now be "..(quiet_backups and "quiet" or "visible")..".")
   else
      Note("Invalid option for mapper backups. The current automated backup setting is '"..((GetTimerInfo("backup_timer", 6) and "on") or "off").."'. Try 'mapper backups on' or 'mapper backups off' to set your desired preference.")
   end
</send>
</alias>

<alias
   match="^mapper ignore mismatch( .*)? (true|false)$"
   enabled="y"
   regexp="y"
   sequence="100"
   send_to="12"
><send>
   ignore_mismatch("%1", %2)
</send></alias>

<alias
   match="^mapper noportal (.*) (true|false)$"
   enabled="y"
   regexp="y"
   sequence="100"
   send_to="12"
><send>
   manual_noportal("%1", %2 and 1 or 0, true)
</send></alias>

<alias
   match="^mapper norecall (.*) (true|false)$"
   enabled="y"
   regexp="y"
   sequence="100"
   send_to="12"
><send>
   manual_norecall("%1", %2 and 1 or 0, true)
</send></alias>

<alias
   match="mapper reset"
   enabled="y"
   sequence="100"
   ignore_case="y"
   script="reset_mapper"
></alias>

<alias
   match="^mapper quicklist( (on|off))?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="quick_mode_toggle"
></alias>

<alias
   match="^mapper shownotes( (on|off))?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="shownote_toggle"
></alias>

<alias
   match="^mapper compact( (on|off))?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="compact_mode_toggle"
></alias>

<alias
   match="mapper zoom out"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   script="mapper.zoom_out"
></alias>

<alias
   match="mapper zoom in"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   script="mapper.zoom_in"
></alias>

<alias
   match="mapper lockexit"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   send_to="12"
><send>
   room_lock_exit()
</send>
</alias>

<alias
   match="mapper findpath * *"
   enabled="y"
   sequence="100"
   omit_from_output="y"
   script="printpath_alias"
></alias>

<alias
   match="mapper find *"
   enabled="y"
   sequence="100"
   script="map_find"
   regexp="n"
></alias>

<alias
   match="^mapper shops?( .+)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="map_shops"
></alias>

<alias
   match="^mapper train( .+)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="map_trainers"
></alias>

<alias
   match="^mapper quest( .+)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="map_quests"
></alias>

<alias
   match="^mapper heal( .+)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="map_healers"
></alias>

<alias
   match="mapper goto *"
   enabled="y"
   sequence="100"
   script="map_goto"
></alias>

<alias
   match="mapper walkto *"
   enabled="y"
   sequence="100"
   script="map_goto"
></alias>

<alias
   match="^mapper where( .*)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   script="map_where"
></alias>

<alias
   match="mapper thisroom"
   enabled="y"
   sequence="100"
   script="show_this_room"
   regexp="n"
></alias>

<alias
   match="^mapper unmapped( .+)?$"
   enabled="y"
   sequence="100"
   script="show_known_unmapped_exits"
   regexp="y"
></alias>

<alias
   match="^mapper notes( (.+))?$"
   enabled="y"
   regexp="y"
   sequence="100"
   script="map_notes"
></alias>

<alias
   match="mapper bookmarks"
   enabled="y"
   sequence="100"
   script="map_notes"
></alias>

<alias
   match="mapper purgezone*"
   enabled="y"
   sequence="100"
   script="map_purgezone"
></alias>

<alias
   match="mapper purgeroom"
   enabled="y"
   sequence="100"
   send_to="12"
><send>
   if not current_room then
      print ("PURGEROOM FAILED: No room received from the mud yet. Try using the 'LOOK' command first.")
   else
      map_purgeroom(current_room)
      mapper.draw (current_room)
   end
</send>
</alias>

<alias
   match="mapper clearcache"
   enabled="y"
   sequence="100"
   script="map_clearcache"
></alias>

<alias
   match="mapper resume"
   enabled="y"
   sequence="100"
   script="map_resume"
></alias>

<alias
   match="mapper stop"
   enabled="y"
   sequence="100"
   script="mapper.cancel_speedwalk"
></alias>

<alias
   match="mapper hide"
   enabled="y"
   sequence="100"
   send_to="12"
><send>
   window_hidden = true
   mapper.hide()
</send></alias>

<alias
   match="mapper show"
   enabled="y"
   sequence="100"
   send_to="12"
><send>
   window_hidden = false
   mapper.show()
</send></alias>

<alias
   match="mapper area *"
   enabled="y"
   sequence="100"
   script="map_area"
   regexp="n"
></alias>

<alias
   match="mapper list *"
   enabled="y"
   sequence="100"
   script="map_list"
   regexp="n"
></alias>

<alias
   match="^mapper next( .*)?$"
   enabled="y"
   regexp="y"
   sequence="100"
   send_to="12"
>
<send>
mapper.gotoNextResult(Trim("%1"))
</send>
</alias>

<alias
   match="^mapper portals( .*)?$"
   enabled="y"
   sequence="100"
   script="map_portal_list"
   regexp="y"
></alias>

<alias
   match="^mapper portal (.+)$"
   enabled="y"
   sequence="100"
   script="map_portal"
   regexp="y"
></alias>

<alias
   match="^mapper fullportal {(.+)} {(.+)} (\d+)$"
   enabled="y"
   sequence="100"
   script="map_portal"
   regexp="y"
></alias>

<alias
   name="DELETE"
   match="^mapper delete portal (.+)$"
   enabled="y"
   sequence="100"
   script="map_portal_edit"
   regexp="y"
></alias>

<alias
   match="^mapper change portal (#\d+) {(.+)}$"
   enabled="y"
   sequence="100"
   script="map_portal_edit"
   regexp="y"
></alias>

<alias
   match="^mapper change portal {(.+)} {(.+)}$"
   enabled="y"
   sequence="100"
   script="map_portal_edit"
   regexp="y"
></alias>

<alias
   match="mapper portalrecall *"
   enabled="y"
   sequence="100"
   script="map_portal_recall"
   regexp="n"
></alias>

<alias
   match="mapper portallevel *"
   enabled="y"
   sequence="100"
   script="map_portal_level"
   regexp="n"
></alias>

<alias
   match="^mapper delete cexits$"
   enabled="y"
   sequence="100"
   script="map_cexits_delete"
   regexp="y"
></alias>

<alias
   match="^mapper delete exits to (.+)$"
   enabled="y"
   sequence="100"
   script="map_exit_delete_to"
   regexp="y"
></alias>

<alias
   match="^mapper delete exits from (.+)$"
   enabled="y"
   sequence="100"
   script="map_exit_delete_from"
   regexp="y"
></alias>

<alias
   match="^mapper delete note$"
   enabled="y"
   sequence="100"
   send_to="12"
   regexp="y"
>
<send>
   local notes, found = room_find_notes(current_room)
   if found then
      room_delete_note(current_room, notes)
   else
      mapper.mapprint("No note found here to delete.")
   end
</send>
</alias>

<alias
   match="^mapper purge portals$"
   enabled="y"
   sequence="100"
   regexp="y"
   send_to="12"
>
<send>
   Note("Are you sure you want to purge all portal exits? To confirm type 'mapper purge portals confirm'.")
   toConfirm = "purge portals"
   EnableAlias("confirm_catch", true)
</send>
</alias>

<alias
   match="^mapper purge cexits$"
   enabled="y"
   sequence="100"
   regexp="y"
   send_to="12"
>
<send>
   Note("Are you sure you want to purge all custom mapper exits? To confirm type 'mapper purge cexits confirm'.")
   toConfirm = "purge cexits"
   EnableAlias("confirm_catch", true)
</send>
</alias>

<alias
   match="^(.*)$"
   enabled="n"
   name="confirm_catch"
   keep_evaluating="n"
   sequence="1"
   regexp="y"
   send_to="12"
>
<send>
   if "%1" == "mapper purge cexits confirm" and toConfirm == "purge cexits" then
      map_cexits_purge()
   elseif "%1" == "mapper purge portals confirm" and toConfirm == "purge portals" then
      map_portal_purge()
   elseif "%1" == "mapper set database confirm" and toConfirm == "set database" then
      set_database(new_database)
   else
      Note(string.format("Failed to confirm '%s'. Aborting.", toConfirm))
   end
   new_database = ""
   toConfirm = ""
   EnableAlias("confirm_catch", false)
</send>
</alias>

<alias
   match="mapper cexits *"
   enabled="y"
   sequence="100"
   script="custom_exit_list"
   regexp="n"
></alias>

<alias
   match="mapper cexits"
   enabled="y"
   sequence="100"
   script="custom_exit_list"
   regexp="n"
></alias>

<alias
   match="^mapper cexit (.+)$"
   enabled="y"
   sequence="100"
   script="custom_exit"
   regexp="y"
></alias>

<alias
   match="^mapper fullcexit {(.+)} (\S+) (\S+)( \d+)?$"
   enabled="y"
   sequence="100"
   script="custom_fullexit"
   regexp="y"
></alias>

<alias
   match="^mapper cexit_wait (.+)$"
   enabled="y"
   sequence="100"
   script="change_cexit_delay"
   regexp="y"
></alias>

<alias
   match="mapper areas"
   enabled="y"
   sequence="100"
   script="map_areas"
></alias>

<alias
   match="mapper areas *"
   enabled="y"
   sequence="100"
   script="map_areas"
></alias>

<alias
   match="^mapper addnote( .*)?$"
   regexp="y"
   enabled="y"
   sequence="100"
   send_to="12"
>
<send>
   room_edit_note(nil,nil,Trim("%1"))
</send>
</alias>

<alias
   match="^mapper bounceportal( .*)?$"
   enabled="y"
   regexp="y"
   sequence="100"
   script="map_bounceportal"
></alias>

<alias
   match="^mapper bouncerecall( .*)?$"
   enabled="y"
   regexp="y"
   sequence="100"
   script="map_bouncerecall"
></alias>

<alias
   match="^mapper help( (.*))?$"
   enabled="y"
   regexp="y"
   script="OnHelp"
></alias>

<alias
   match="^mapper updown$"
   enabled="y"
   regexp="y"
   send_to="12"
>
<send>
   show_up_down = not show_up_down
   mapper.show_up_down = show_up_down
   SetVariable("show_up_down", show_up_down and "1" or "0")
   mapper.draw(current_room)
</send>
</alias>

<alias
   match="^mapper underlines?$"
   enabled="y"
   regexp="y"
   send_to="12"
>
<send>
   NoUnderline_hyperlinks = not NoUnderline_hyperlinks
   Note("Mapper hyperlinks will " ..(NoUnderline_hyperlinks and "no longer" or "now").." be underlined.")
   SetVariable("NoUnderline_hyperlinks", NoUnderline_hyperlinks and "1" or "0")
</send>
</alias>
</aliases>

<triggers>

<!-- stop a step-by-step speedwalk when a move fails -->
<trigger
   enabled="y"
   match="^(Alas, you cannot go that way\.|You are too exhausted\.|You need a boat to go there\.|You can't fly\.|You can't take your mount there\.|You are too tired\.|The \w+ is closed\.|You must be standing first\.|Nah\.\.\. You feel too relaxed\.\.\.|In your dreams, or what\?|No way!  You are still fighting!|You aren't allowed in there\.|That room is private right now\.)$"
   regexp="y"
   script="mapper.cancel_speedwalk"
   sequence="100"
></trigger>

</triggers>

<script>
local show_vnums = &show_vnums;
local show_timing = &show_timing;
local show_completed = &show_completed;
local show_database_mods = &show_database_mods;
local show_other_areas = &show_other_areas;
local speedwalk_prefix = "&speedwalk_prefix;"

<![CDATA[

require "serialize"
require "tprint"
require "wait"

-- mim_mapper.lua lives in this plugin's own directory
package.path = GetPluginInfo (GetPluginID (), 20) .. "?.lua;" .. package.path
mapper = require "mim_mapper"

strip_colours = mapper.strip_colours

-- the GMCP data comes from the MIM_GMCP_handler plugin
GMCP_HANDLER = "c8de491c3162475692a1d8d4"
gmcpdata = {}

function Send_GMCP_Packet (what)
   CallPlugin (GMCP_HANDLER, "Send_GMCP_Packet", what)
end -- Send_GMCP_Packet

-- one GMCP package from the handler, as a table
function gmcp (what)
   local rc, s = CallPlugin (GMCP_HANDLER, "gmcpdata_as_string", what)
   if rc ~= error_code.eOK or s == nil or s == "" then
      return nil
   end
   local f = loadstring ("return " .. s)
   local ok, v = pcall (f or function () end)
   if ok then
      return v
   end
end -- gmcp

-- a field of the last Room.Info (eg. "num", "zone"), as a string
function gmcpval (field)
   local v = gmcpdata
   for part in string.gmatch (field, "[^.]+") do
      if type (v) ~= "table" then
         return nil
      end
      v = v [part]
   end
   if v == nil or type (v) == "table" then
      return v
   end
   return tostring (v)
end -- gmcpval

function capitalize (s)
   return (string.gsub (tostring (s or ""), "^%l", string.upper))
end -- capitalize

-- "a b c":split(" ") -> {"a","b","c"}; plain = no patterns; max = most splits
function string.split (s, sep, plain, max)
   local parts, start = {}, 1
   if sep == nil or sep == "" then
      return { s }
   end
   while true do
      if max and #parts >= max then
         table.insert (parts, s:sub (start))
         return parts
      end
      local a, b = s:find (sep, start, plain)
      if not a then
         table.insert (parts, s:sub (start))
         return parts
      end
      table.insert (parts, s:sub (start, a - 1))
      start = b + 1
   end
end -- string.split

toConfirm = ""

-- Mapper Variables --
blinded = false
mylevel = 0
mytier = 0
show_up_down = ((GetVariable("show_up_down") or "0") == "1")  -- default false
NoUnderline_hyperlinks = GetVariable("NoUnderline_hyperlinks") ~= "0" -- default true
window_hidden =  ((GetVariable("window_hidden") or "0") == "1") -- default false
use_compression = tonumber(GetVariable("use_compression") or 0)
quick_mode = ((GetVariable("quick_mode") or "1") == "1") -- default true
shownotes = ((GetVariable("shownotes") or "1") == "1") -- default true
quiet_backups = true -- just at the beginning
local rooms = {}
local areas = {}
local environments = {}
local user_terrain_colour = {}
local performing_maintenance = false
compact_mode = ((GetVariable("compact_mode") or "0") == "1") -- default false

room_not_in_database = {}
room_in_database = {}

bounce_recall = nil
bounce_portal = nil

-- Create Database Variables --

count = 0
roomcount = 0
endroomcount = 0
areacount = 0
coordcount = 0
directioncount = 0
environmentcount = 0

directions = {}
current_room = nil

function reset_mapper()
   mapper.reset_pos()
   SaveState()
   prev_quiet_backups = quiet_backups
   quiet_backups = true
   OnPluginInstall()
   quiet_backups = prev_quiet_backups
end

convert_direction = {
   north = "n",
   south = "s",
   east = "e",
   west = "w",
   up = "u",
   down = "d"
}

forced_opened = false
force_nests = 0
function forceOpenDB()
   force_nests = force_nests+1
   if not db:isopen() then
      forced_opened = true
      -- print("Forcing open")
      db = assert (sqlite3.open(GetInfo (66) .. db_name .. ".db"))
   end
end

function closeDBifForcedOpen()
   force_nests = force_nests-1
   if forced_opened and (force_nests <= 0) then
      force_nests = 0
      forced_opened = false
      -- print("Forcing closed")
      db:close()
   end
end

function DBisOpen(warn)
   if db:isopen() then
      return true
   end
   if warn then
       Note("MAPPER ERROR: The map database is closed for safety when not connected to the game. If you want to change the DB, please connect first.")
   end
   return false
end

max_retries = 10
sleep_duration = 1
function dbnrowsWRAPPER(query)
   forceOpenDB()
   local ok, iter, vm, i = pcall(db.nrows, db, query)

   local count = 0
   while (not ok) and (count < max_retries) do
      local code = db:errcode()
      ColourNote("yellow", "red", string.format("MAPPER ERROR: %s. Retrying %dX just in case: `%s`", db:errmsg():upper(), max_retries, query))
      if (code ~= sqlite3.BUSY) and (code ~= sqlite3.LOCKED) then
         break
      end
      db:exec("ROLLBACK")
      local socket = require "socket"
      socket.sleep(sleep_duration)
      ok, iter, vm, i = pcall(db.nrows, db, query)
      count = count + 1
   end

   if (not dbcheck(db:errcode(), db:errmsg(), query)) and (count > 0) then
      ColourNote("black", "greenyellow", string.format("MAPPER SUCCESS AFTER RETRIES: `%s`", query))
   end

   local function itwrap(vm, i)
      retval = iter(vm, i)
      if not retval then
         closeDBifForcedOpen()
         return nil
      end
      return retval
   end
   return itwrap,vm,i
end

function dbCheckExecute(query)
   forceOpenDB()
   local code = db:exec(query)
   local count = 0
   while ((code == sqlite3.BUSY) or (code == sqlite3.LOCKED)) and (count < max_retries) do
      ColourNote("yellow", "red", string.format("MAPPER ERROR: %s. Retrying %dX just in case: `%s`", db:errmsg():upper(), max_retries, query))
      db:exec("ROLLBACK")
      local socket = require "socket"
      socket.sleep(sleep_duration)
      code = db:exec(query)
      count = count + 1
   end

   if (not dbcheck(code, db:errmsg(), query)) and (count > 0) then
      ColourNote("black", "greenyellow", string.format("MAPPER SUCCESS AFTER RETRIES: `%s`", query))
   end

   closeDBifForcedOpen()
end

function dbcheck (code, msg, query)
   if code ~= sqlite3.OK and    -- no error
      code ~= sqlite3.ROW and   -- completed OK with another row of data
      code ~= sqlite3.DONE then -- completed OK, no more rows
         local err = msg.."\n\nCODE: "..code.."\nQUERY: "..query.."\n"
         db:exec("ROLLBACK")      -- rollback any transaction to unlock the database
         closeDBifForcedOpen()
         error(err, 3)            -- show error in caller's context
   end -- if
end -- dbcheck

function fixsql (s)
   if s then
      return "'" .. (string.gsub (s, "'", "''")) .. "'" -- replace single quotes with two lots of single quotes
   else
      return "NULL"
   end -- if
end -- fixsql

function fixbool (b)
   if b then
      return 1
   else
      return 0
   end -- if
end -- fixbool

function PORTALS_QUERY(filter)
   if not filter then
      filter = "%"
   end
   return "select rooms.area,rooms.name,exits.touid,exits.fromuid,exits.dir,exits.level from exits left outer join rooms on rooms.uid=exits.touid where exits.fromuid in ('*','**') and rooms.area LIKE '"..filter.."' order by rooms.area,exits.touid"
end

function map_bounceportal (name, line, wildcards)
   wildcards[1] = Trim(wildcards[1])
   if wildcards[1]=="" then
      if bounce_portal and bounce_portal.dir then
         Note("\nBOUNCEPORTAL: Currently set to '"..bounce_portal.dir.."'")
      else
         Note("\nBOUNCEPORTAL: Not currently set.")
      end
      return
   elseif wildcards[1]=="clear" then
      bounce_portal = nil
      Note("\nBOUNCEPORTAL: cleared.")
      dbCheckExecute(string.format("DELETE from storage where name is %s;", fixsql("bounce_portal")))
      return
   end

   local pnum = tonumber(wildcards[1])

   if pnum==nil then
      Note("\nBOUNCEPORTAL FAILED: The required parameter for mapper bounceportal is <portal_index>. Current portal indexes can be found in the 'mapper portals' output.\n")
      return
   end

   local count = 1
   local found = false
   for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
      if count == pnum then
         if row.fromuid == "*" then
            bounce_portal = {dir=row.dir, uid=row.touid}
            Note("\nBOUNCEPORTAL: Set portal #"..count.." ("..row.dir..") as the bounce portal for portal-friendly norecall rooms.")
            dbCheckExecute(string.format("INSERT OR REPLACE INTO storage (name, data) VALUES (%s,%s);", fixsql("bounce_portal"), fixsql(serialize.save("bounce_portal"))))
         else
            Note("\nBOUNCEPORTAL FAILED: Portal #"..pnum.." is a recall portal. You must choose a mapper portal that does not use either the recall or home commands for the bounce portal.")
         end
         found = true
      end
      count = count + 1
   end
   if found == false then
      Note(string.format("\nBOUNCEPORTAL FAILED: Did not find index %s in the list of portals. Try 'mapper portals' to see the list.\n", pnum))
   end
end

function map_bouncerecall (name, line, wildcards)
   wildcards[1] = Trim(wildcards[1])
   if wildcards[1]=="" then
      if bounce_recall and bounce_recall.dir then
         Note("\nBOUNCERECALL: Currently set to '"..bounce_recall.dir.."'")
      else
         Note("\nBOUNCERECALL: Not currently set.")
      end
      return
   elseif wildcards[1]=="clear" then
      bounce_recall = nil
      Note("\nBOUNCERECALL: cleared.")
      dbCheckExecute(string.format("DELETE from storage where name is %s;", fixsql("bounce_recall")))
      return
   end


   local pnum = tonumber(wildcards[1])

   if pnum==nil then
      Note("\nBOUNCERECALL FAILED: The required parameter for mapper bouncerecall is <recall_portal_index>. Current portal indexes can be found in the 'mapper portals' output.\n")
      return
   end

   local count = 1
   local found = false
   for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
      if count == pnum then
         if row.fromuid == "**" then
            bounce_recall = {dir=row.dir, uid=row.touid}
            Note("\nBOUNCERECALL: Set recall portal #"..pnum.." ("..row.dir..") as the bounce recall for recall-friendly noportal rooms.")
            dbCheckExecute(string.format("INSERT OR REPLACE INTO storage (name, data) VALUES (%s,%s);", fixsql("bounce_recall"), fixsql(serialize.save("bounce_recall"))))
         else
            Note("\nBOUNCERECALL FAILED: Portal #"..pnum.." is not a recall portal. You must choose a mapper portal that uses either the recall or home commands for the bounce recall.")
         end
         found = true
      end
      count = count + 1
   end
   if found == false then
      world.Note(string.format("\nBOUNCERECALL FAILED: Did not find index %s in the list of portals. Try 'mapper portals' to see the list.\n", pnum))
   end
end

function map_areas (name, line, wildcards)
   local line = ""
   local query = ""
   local area = wildcards[1] or ""
   local count = 0

   local hr = "| keyword    | Area Name                               | Explored |"
   local hl = "+------------+-----------------------------------------+----------+"
   local fmt = "| %10.10s | %-39.39s | %8.8s |"
   if area == "" then
      query = "SELECT uid, name FROM areas WHERE uid in (SELECT DISTINCT area FROM rooms) ORDER BY name;"
      intro = "The following areas have been mapped:"
   else
      query = string.format("SELECT uid, name FROM areas WHERE name LIKE %s AND uid in (SELECT DISTINCT area FROM rooms) ORDER BY name;", fixsql("%"..area.."%"))
      intro = string.format("The following areas matching '%s' have been mapped:",area)
   end -- if area

   Note ("\n"..intro)
   Note (hl)
   Note (hr)
   Note (hl)
   local total_explored = 0
   for row in dbnrowsWRAPPER(query) do
      query2 = string.format("SELECT count(uid) as count FROM rooms WHERE area=%s;",fixsql(row.uid))
      for row2 in dbnrowsWRAPPER(query2) do
         line = string.format(fmt,row.uid, row.name, row2.count)
         total_explored = total_explored + row2.count
      end
      Note (line)
      count = count + 1
      Repaint()
   end

   Note (hl)
   line = string.format ("Found %s areas containing %s rooms mapped.\n", count, total_explored)
   Note (line)
end

-- map_list function contributed by Spartacus
function map_list (name, line, wildcards)
   -- ok, so if I want to lookup a room in my db, I don't want it only if the mapper can find a sw in a certain # of rooms.
   -- if it is in the db, I want its vnum and area, so that I can figure out how to get there if the mapper does not know.
   local area = ""
   local count = 0
   Note("+------------------------------ START OF SEARCH -------------------------------+")
   -- find matching rooms using FTS3
   local name = "%"..wildcards[1].."%"
   if string.sub(wildcards[1],1,1) == "\"" and string.sub(wildcards[1],-1) == "\"" then
      name = string.sub(wildcards[1],2,-2)
   end

   -- faster than "SELECT uid, name, area FROM rooms WHERE name LIKE %s ORDER BY area LIMIT 101"
   for row in dbnrowsWRAPPER(string.format ("SELECT rooms_lookup.uid as uid, rooms_lookup.name as name, area FROM (select uid, name FROM rooms_lookup WHERE name LIKE %s) AS rooms_lookup JOIN rooms ON rooms_lookup.uid = rooms.uid ORDER BY area LIMIT 101;", fixsql (name))) do
      if count < 100 then
         print (string.format("(%s) %s is in area \"%s\"",row.uid, row.name, row.area))
      end
      count = count + 1
   end   -- finding room
   if count > 100 then
      mapper.mapprint(string.format("More than 100 search results found. Aborting query. Try a more specific search phrase than '%s'.",wildcards[1]))
   end
   Note("+-------------------------------- END OF SEARCH -------------------------------+")
end -- map_list

-- accessible only through CallPlugin
function map_delete_exits_for_many_rooms (...)
   local rooms_to_delete_exits_from = {...}
   for i,v in ipairs(rooms_to_delete_exits_from) do
      rooms_to_delete_exits_from[i] = tostring(v)
   end

   local lookup = {}
   for i,v in ipairs(rooms_to_delete_exits_from) do
      lookup[v] = true
   end

   local rooms_string = table.concat(rooms_to_delete_exits_from, ",")
   local query = string.format("delete from exits where touid in (%s) or fromuid in (%s)", rooms_string, rooms_string)
   dbCheckExecute(query)

   for k,v in pairs(rooms) do
      for j,u in pairs(v.exits) do
         if lookup[u] then
            v.exits[j] = nil
            v.exit_locks[j] = nil
         end
      end
      if lookup[k] then
         v.exits = {}
         v.exit_locks = {}
      end
   end

   mapper.draw (current_room)
   print("Purged all exits for rooms: "..rooms_string)
end

function map_cexits_delete (name, line, wildcards)
   local query = string.format("delete from exits where fromuid=%s and dir not in ('n','s','e','w','d','u');", fixsql(current_room))
   dbCheckExecute(query)
   for k,v in pairs(rooms[current_room].exits) do
      if not directions[k] then
         local dest = rooms[current_room].exits[k]
         if not rooms[dest] then
            load_room_from_database(dest)
         end
         print("Found custom exit \""..k.."\" to room "..dest.." \""..rooms[dest].name.."\"")
         rooms[current_room].exits[k] = nil
         rooms[current_room].exit_locks[k] = nil
      end
   end
   mapper.draw (current_room)
   print("Removed custom exits from the current room.")
end

function map_exit_delete_to (name, line, wildcards)
   if not current_room then
      Note("EXIT DELETE ERROR: The mapper doesn't know where you are. Type 'LOOK' and try again.")
      return
   end
   local query = string.format("delete from exits where fromuid=%s and touid=%s;", fixsql(current_room), fixsql(wildcards[1]))
   dbCheckExecute(query)
   local found = false
   for k,v in pairs(rooms[current_room].exits) do
      if v == wildcards[1] then
         print("Deleted exit \""..k.."\" to room "..v)
         rooms[current_room].exits[k] = nil
         rooms[current_room].exit_locks[k] = nil
         found = true
      end
   end
   if not found then
      print("No exits between here and "..wildcards[1].." were found.")
   else
      mapper.draw (current_room)
   end
end

function map_exit_delete_from (name, line, wildcards)
   if not current_room then
      Note("EXIT DELETE ERROR: The mapper doesn't know where you are. Type 'LOOK' and try again.")
      return
   end
   local query = string.format("delete from exits where fromuid=%s and touid=%s;", fixsql(wildcards[1]), fixsql(current_room))
   dbCheckExecute(query)
   if not rooms[wildcards[1]] then
      load_room_from_database(wildcards[1])
   end
   local found = false
   if rooms[wildcards[1]] then
      for k,v in pairs(rooms[wildcards[1]].exits) do
         if v == current_room then
            print("Deleted exit \""..k.."\" from room "..wildcards[1])
            rooms[wildcards[1]].exits[k] = nil
            rooms[wildcards[1]].exit_locks[k] = nil
            found = true
         end
      end
   end
   if not found then
      print("No exits between "..wildcards[1].." and here were found.")
   else
      mapper.draw (current_room)
   end
end


function map_cexits_purge (name, line, wildcards)
   local query = "delete from exits where dir not in ('n','s','e','w','d','u');"
   dbCheckExecute(query)
   for k,v in pairs(rooms) do
      for l,w in pairs(v.exits) do
         if not directions[l] then
            v.exits[l] = nil
            v.exit_locks[l] = nil
         end
      end
   end
   mapper.draw (current_room)
   print("Purged all custom exits.")
end

function custom_exit_list (name, line, wildcards)
   local line = ""
   local count = 0
   local query
   local display_limit = 30

   area = Trim(wildcards [1] or "")
   query = string.format("select uid, name, area, dir, touid from rooms inner join exits on rooms.uid = fromuid where lower(area) like %s and dir not in ('n','s','e','w','d','u') and fromuid not in ('*','**') order by area, uid", fixsql("%" .. area .. "%"))

   if area == "" then
      intro = "The following rooms have custom exits:"
   else
      if area == "here" then

         if current_room and gmcproom.area then
            area = gmcproom.area
         else
            Note("CEXITS HERE ERROR: The mapper doesn't know where you are. Type 'LOOK' and try again.")
            return
         end

         query = string.format("select uid, name, area, dir, touid from rooms inner join exits on rooms.uid = fromuid where lower(area) is %s and dir not in ('n','s','e','w','d','u') and fromuid not in ('*','**') order by uid", fixsql(area))
         intro = "The following rooms in the current area have custom exits:"
      elseif area == "thisroom" then
         if not current_room then
            Note("CEXITS THISROOM ERROR: The mapper doesn't know where you are. Type 'LOOK' and try again.")
            return
         end
         query = string.format("select uid, name, area, dir, touid from rooms inner join exits on rooms.uid = fromuid where fromuid=%s and dir not in ('n','s','e','w','d','u')", fixsql(current_room))
         intro = "The following custom exits are in this room:"
      else
         intro = string.format("The following rooms in areas partially matching '%s' have custom exits:",area)
      end
   end

   hr = "| area       | room name            | rm uid  | dir            | to uid  |"
   hl = "+------------+----------------------+---------+----------------+---------+"

   -- area - room name - room uid - direction - destination uid
   fmt = "| %10.10s | %-20.20s | %7.7s | %-14.14s | %7.7s |"
   -- print ("Query: " .. query)
   world.Note ("\n"..intro)
   world.Note (hl)
   world.Note (hr)
   world.Note (hl)
   for row in dbnrowsWRAPPER(query) do
      line = string.format(fmt,row.area, row.name, row.uid, row.dir, row.touid)
      Hyperlink(string.format("mapper goto %s",row.uid), line, string.format("%s",row.dir), "", "", false, NoUnderline_hyperlinks)
      print("")
      count = count + 1
      Repaint()
   end -- custom exits query
   world.Note (hl)
   line = string.format ("Found %s custom exits.", count)
   print (line.."\n")
end -- custom_exits_list


function show_this_room (name, line, wildcards)
   local room = rooms[current_room]
   local room_uid = current_room
   if room_uid:sub(1,5) == "nomap" then
      room_uid = room_uid.." (this room isn't mappable)"
   end
   if room ~= nil then
      Note("Details about this room:")
      Note("+---------------------------+")
      Note("Name: "..(room.name or ""))
      Note("ID: "..(room_uid or ""))
      Note("Area: "..(room.area or ""))
      Note("Terrain: "..(room.terrain or ""))
      Note("Info: "..(room.info or ""))
      Note("Notes: "..(room.notes or ""))
      local flags = ''
      if room.noportal == 1 then
         flags = flags .. ' noportal'
      end
      if room.norecall == 1 then
         flags = flags .. ' norecall'
      end
      Note("Flags:", flags)
      Note("Exits: ")
      tprint(room.exits)
      Tell("Exit locks: ")
      if room.exit_locks then
         Note("")
         tprint(room.exit_locks)
      else
         Note("none")
      end
      Note("Ignore exits mismatch: ", room.ignore_exits_mismatch)
      Note("+---------------------------+\n")
   else
      Note("THISROOM ERROR: You need to type 'LOOK' first to initialize the mapper before trying to get room information.")
   end
end -- show_this_room


-- show_known_unmapped_exits function contributed by Spartacus
function show_known_unmapped_exits (name, line, wildcards)
   -- I want to get a list of exits whose destinations are not yet in the rooms table (i.e. I have not gone through the exit?)
   -- select uid, name, area from rooms inner join exits on rooms.uid = fromuid where area = area and touid not in (select uid from rooms)
   local line = ""
   local count = 0
   local query
   local display_limit = 30

   area = Trim(wildcards [1] or "")

   if area == "" then
      query = "select area, count(dir) as cnt from rooms inner join exits on rooms.uid = fromuid where touid not in (select uid from rooms) and touid != -1 group by area"
      hr = "| area       | count |"
      hl = "+------------+-------+"
      intro = "The following areas have unmapped exits:"
      fmt = "| %10.10s | %5d |"
   else
      if area == "here" then

         if gmcproom and gmcproom.area then
            area = gmcproom.area
         else
            Note("UNMAPPED HERE ERROR: The mapper doesn't know where you are. Type 'LOOK' and try again.")
            return
         end

         intro = "The following rooms in the current area have unmapped exits:"
      else
         intro = string.format("The following rooms in areas matching '%s' have unmapped exits:",area)
         area = "%"..area.."%"
      end
      query = string.format("select uid, name, area, dir, touid from rooms inner join exits on rooms.uid = fromuid where lower(area) like %s and touid not in (select uid from rooms) and touid != -1 order by area, uid", fixsql(area))
      hr = "| area       | room name            | rm uid  | dir | to uid  |"
      hl = "+------------+----------------------+---------+-----+---------+"
      -- area - room name - room uid - direction - destination uid
      fmt = "| %10.10s | %-20.20s | %7.7s | %-3.3s | %7.7s |"
   end -- if area

   -- print ("Query: " .. query)
   world.Note ("\n"..intro)
   world.Note (hl)
   world.Note (hr)
   world.Note (hl)
   for row in dbnrowsWRAPPER(query) do
      if area == "" then
         line = string.format(fmt,row.area, row.cnt)
         world.Note (line)
         count = count + row.cnt
      else
         line = string.format(fmt,row.area, row.name, row.uid, row.dir, row.touid)
         Hyperlink(string.format("mapper goto %s",row.uid), line, "Click to attempt to walk here", "", "", false, NoUnderline_hyperlinks)
         print("")
         count = count + 1
      end -- if count
   end -- unmapped rooms query
   world.Note (hl)
   line = string.format ("Found %s unmapped exits.", count)
   print (line.."\n")
end -- show_known_unmapped_exits

function map_portal_edit (name, line, wildcards)
   local keywords = wildcards[1]
   local new_command = wildcards[2]
   local target_index = nil
   if string.sub(keywords,1,1) == "#" and type(tonumber(string.sub(keywords,2)))=="number" then
      target_index = tonumber(string.sub(keywords,2))
      local count = 0
      local found = false
      for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
         count = count + 1
         if count == target_index then
            keywords = row.dir
            found = true
         end
      end
      if found == false then
         world.Note(string.format("\n%s FAILED: Did not find portal #%s in the list of portals. Try 'mapper portals' to see the list.\n", name, target_index))
         return
      end
   end

   local portal_exists = false
   for n in dbnrowsWRAPPER (string.format ("SELECT * FROM exits WHERE fromuid in ('*','**') AND dir = %s", fixsql(keywords))) do
      portal_exists = true
   end

   if portal_exists then
      if name == "DELETE" then
         print (string.format("Deleted mapper portal"..(((target_index ~= nil) and " index #"..target_index) or "").." with keywords '%s'.", keywords))
      else
         print (string.format("Changed mapper portal"..(((target_index ~= nil) and " index #"..target_index) or "").." to command '%s'.", new_command))
      end
   else
      print (string.format("%s FAILED: Did not find a mapper portal with keywords '%s'.", name or "CHANGE", keywords))
   end

   if name == "DELETE" then
      query = string.format("DELETE FROM exits WHERE fromuid in ('*','**') AND dir = %s;", fixsql(keywords))
   else
      query = string.format("UPDATE exits SET dir = %s WHERE fromuid in ('*', '**') AND dir = %s;", fixsql(new_command), fixsql(keywords))
   end
   dbCheckExecute(query)
   check_bounce_consistency()
end -- map_portal_edit

function map_portal_purge(name, line, wildcards)
   query = "DELETE FROM exits WHERE fromuid in ('*','**');"
   dbCheckExecute(query)
   print ("Purged all mapper portals.")
   map_bouncerecall(nil, nil, {"clear"})
   map_bounceportal(nil, nil, {"clear"})
end

function positive_integer_check(input)
   input = tonumber(input)
   if input == nil or input < 0 or input % 1 ~= 0 then
      return false
   else
      return true
   end
end

function level_check(input)
   input = tonumber(input)
   if positive_integer_check(input) == false then
      return false
   else
      return true
   end
end

function map_portal_recall (name, line, wildcards)
   -- flag a portal as using "recall"
   local query = "INSERT OR REPLACE INTO rooms (uid, name, area) VALUES ('**', '___HERE___', '___EVERYWHERE___')"
   dbCheckExecute(query)

   local pnum = tonumber(wildcards[1])

   if pnum==nil then
      world.Note("\nPORTALRECALL FAILED: The required parameter for mapper portalrecall is <portal_index>. Current portal indexes can be found in the 'mapper portals' output.\n")
      return
   end

   local count = 1
   local found = false
   for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
      if count == pnum then

         -- toggle between '*' and '**'
         query = string.format ([[
            INSERT OR REPLACE INTO exits (dir, fromuid, touid, level)
            VALUES (%s, %s, %s, %s);
            ]],
            fixsql(row.dir),      -- direction (eg. "home")
            fixsql(((row.fromuid == "*") and "**") or "*"),
            fixsql(row.touid),    -- destination room
            fixsql(row.level)
         )
         dbCheckExecute(query)

         -- remove the old pre-toggle entry
         query = string.format ([[
            DELETE FROM exits WHERE dir=%s AND fromuid=%s AND touid=%s AND level=%s;
            ]],
            fixsql(row.dir),
            fixsql(row.fromuid),
            fixsql(row.touid),    -- destination room
            fixsql(row.level)
         )
         dbCheckExecute(query)

         world.Note(string.format("\nPORTALRECALL: Recall flag %s portal '%s' to '%s'.\n",((row.fromuid == "*") and "added to") or "removed from",row.dir,(row.name or "N/A")))
         found = true
         check_bounce_consistency()
      end
      count = count + 1
   end
   if found == false then
      world.Note(string.format("\nPORTALRECALL FAILED: Did not find index %s in the list of portals. Try 'mapper portals' to see the list.\n", pnum))
   end
end -- map_recall

-- first map_portal function was contributed by Spartacus.
function map_portal (name, line, wildcards)
   -- store portal as an exit from anywhere to the current or given room
   local destination = wildcards[2] or current_room

   if not destination then
      print ("PORTAL FAILED: No room received from the mud yet. Try using the 'LOOK' command first.")
      return
   end

   if not load_room_from_database(destination) then
      print("PORTAL ["..wildcards[1].."] FAILED: Room "..destination.." is unknown.")
      return
   end

   local level = (level_check(wildcards[3]) and wildcards[3]) or tonumber(utils.inputbox (string.format("Please enter the level of your portal to %s, or leave at 0 to not add a level-lock",destination), "Portal Level", "0", "Courier", 9, {validate=level_check}))

   if not level then
      Note("Portal creation cancelled.")
      return
   end

   create_portal(wildcards[1], destination, level)

end -- map_portal

function create_portal(keyword, destination, level)
   local hhp_room_exists = 0
   local query = ""
   keyword = Trim(keyword)
   -- first check to see if our special 'from anywhere' room exists...
   for row in dbnrowsWRAPPER("select * from rooms where uid='*'") do
      hhp_room_exists = hhp_room_exists + 1
   end
   if hhp_room_exists == 0 then
      query = "INSERT OR REPLACE INTO rooms (uid, name, area) VALUES ('*', '___HERE___', '___EVERYWHERE___')"
      dbCheckExecute(query)
   end
   print (string.format("Storing '%s' as a portal to room %s.", keyword, destination))
   print (string.format("\nPortal given minimum level lock of %s.\n", level))
   query = string.format ("INSERT OR REPLACE INTO exits (dir, fromuid, touid, level) VALUES (%s, %s, %s, %s);",
      fixsql  (keyword),
      fixsql  ("*"),           -- from anywhere
      fixsql  (destination),
      fixsql(level) -- minimum level of the portal
   )
   dbCheckExecute(query)
end

function map_portal_level (name, line, wildcards)

   local keys = Trim(wildcards[1]):split("[%s]+")
   local pnum = tonumber(keys[1])
   local level = tonumber(keys[2])
   local quiet = (keys[3] == "quiet")

   if pnum==nil or level==nil then
      world.Note("\nPORTALLEVEL FAILED: The parameters for mapper portallevel are <portal_index> <min_level> [quiet]. Current portal indexes can be found in the 'mapper portals' output.\n")
      return
   end

   if level < 0 then
      level = 0
   end

   local count = 1
   local found = false
   for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
      if count == pnum then
         query = string.format ([[
            INSERT OR REPLACE INTO exits (dir, fromuid, touid, level)
            VALUES (%s, %s, %s, %s);
            ]],
            fixsql(row.dir),  -- direction (eg. "n")
            fixsql(row.fromuid),  -- from '*' or '**'
            fixsql(row.touid),    -- destination room
            fixsql(level)
         )
         dbCheckExecute(query)
         if not quiet then
            world.Note(string.format("\nPortal '%s' to '%s' given minimum level lock of %s.\n",row.dir,row.name,level))
         end
         found = true
      end
      count = count + 1
   end
   if found == false then
      world.Note(string.format("\nPORTALLEVEL FAILED: Did not find index %s in the list of portals. Try 'mapper portals' to see the list.\n", pnum))
   end
end -- map_portal_level

-- map_portal_list function contributed by Spartacus with filtering amendment from Crowley
function map_portal_list (name, line, wildcards)
   local line
   local cmd
   local txt

   local pattern = Trim(wildcards[1])
   if pattern == "here" then
      title = "Mapper portals in the current area:"
      pattern = gmcpval("zone")
   else
      if pattern == "" then
         title = "Mapper portals:"
      else
         title = "Mapper portals to areas matching '"..pattern.."':"
      end
      pattern = "%"..pattern.."%"
   end

   -- show portals stored in the exits table
   local hl = "+-----+------------+----------------------+-------+----------------------+-----+"
   local hr = "|   # | area       | room name            |  vnum | portal commands      | lvl |"

   Note ()
   Note (title)
   Note (hl)
   Note (hr)
   Note (hl)

   local count = 0
   local all_portals = {}
   for row in dbnrowsWRAPPER(PORTALS_QUERY("%%")) do
      count = count + 1
      all_portals[row.dir] = count
   end
   for row in dbnrowsWRAPPER(PORTALS_QUERY(pattern)) do
      line = string.format("|"..((((bounce_portal and (row.dir==bounce_portal.dir)) or (bounce_recall and (row.dir==bounce_recall.dir))) and "*") or " ").."%+3.3s | %-10.10s | %-20.20s | %+5.5s | %-20.20s | %+3.3s |", all_portals[row.dir], row.area or "N/A", row.name or "N/A", row.touid, row.dir, row.level)
      if row.level <= mylevel+(mytier*10) then
         -- make the whole line clickable
         cmd = "mapper goto " .. row.touid
         txt = "click here to run to " .. (row.name or "N/A") .. "\n[ " .. row.dir .. " ]"
         Hyperlink(cmd, line, txt, (((row.fromuid=="*") and "") or "red"), "", false, NoUnderline_hyperlinks)
         Note()
      else
         Note(line)
      end -- if row.level
   end -- for row (portals query)
   Note (hl)
   Note ("|* Indicates designated bouncerecall/bounceportal |")
   Note ("+-------------------------------------------------+\n")
end

BASE_CEXIT_DELAY = 2

function change_cexit_delay(name, line, wildcards)
   temp_cexit_delay = tonumber(wildcards[1])
   if not compact_mode then Note("") end
   if temp_cexit_delay == nil or temp_cexit_delay < BASE_CEXIT_DELAY or temp_cexit_delay > 40 then
      Note("CEXIT_DELAY FAILED: Invalid delay given ("..wildcards[1].."). Must be a number from 2 to 40.")
      temp_cexit_delay = nil
   end
   Note("CEXIT_DELAY: The next mapper custom exit will have ".. (temp_cexit_delay or BASE_CEXIT_DELAY) .." seconds to complete.\n")
end

-- run a command line, pausing at each wait(<seconds>) in it
function ExecuteWithWaits(cexit_command)
   wait.make (function()
      local rest = cexit_command
      while true do
         local a, b, secs = string.find(rest, ";?wait%((%d*%.?%d+)%);?")
         if not a then
            break
         end
         if a > 1 then
            Execute(string.sub(rest, 1, a-1))
         end
         Note("CEXIT WAIT: waiting for "..secs.." seconds before continuing.")
         Repaint()
         wait.time(tonumber(secs))
         rest = string.sub(rest, b+1)
      end
      if rest ~= "" then
         Execute(rest)
      end
   end)
end

-- custom_exit function contributed by Spartacus
function custom_exit (name, line, wildcards)

   local remap = {
      n = "north",
      w = "west",
      s = "south",
      e = "east",
      u = "up",
      d = "down"
   }
   local cexit_command = wildcards [1] or ""
   local cexit_start

   if cexit_command == "" then
      world.Note("Nothing to do!")
      return
   end -- if cexit_command

   -- Note: Since the addition of the ignore_exits_mismatch room flag, I think this is no longer true.
   --       Getting rid of the remap allows one to draw custom exit linkages between nomap rooms.
   ------------------------------------
   -- the current system makes standard letter exit (n,e,s,w,d,u) cexits not usable, so remap them
   -- to their long word forms
   --if remap[cexit_command] then
   --   cexit_command = remap[cexit_command]
   --end
   if current_room then
      cexit_start = current_room
   else
      world.Note("CEXIT FAILED: No room received from the mud yet. Try using the 'LOOK' command first.")
      return
   end -- if current_room

   if cexit_start == "-1" then
      world.Note ("CEXIT FAILED: You cannot link custom exits from unmappable rooms.")
      return
   end

   wait.make (function()
      local cexit_delay = temp_cexit_delay or BASE_CEXIT_DELAY
      local added_waits = 0
      for wait_secs in string.gmatch(cexit_command, "wait%((%d*.?%d+)%)") do
         added_waits = added_waits + tonumber(wait_secs)
      end
      world.Note("CEXIT: WAIT FOR CONFIRMATION BEFORE MOVING.\nThis should take about "..cexit_delay+added_waits.." seconds"..(((added_waits ~= 0) and " (includes "..added_waits.." seconds in added waits)") or "")..".")
      Repaint()
      ExecuteWithWaits(cexit_command)

      wait.time(cexit_delay+added_waits)
      temp_cexit_delay = nil
      cexit_dest = current_room
      if cexit_dest then
         if cexit_dest == "-1" then
            world.Note ("CEXIT FAILED: You cannot link custom exits to unmappable rooms.")
         elseif cexit_dest ~= cexit_start then
            world.Note (string.format("Custom Exit CONFIRMED: %s (%s) -> %s", cexit_start, cexit_command, cexit_dest))
            dbCheckExecute(string.format ("INSERT OR REPLACE INTO exits (dir, fromuid, touid) VALUES (%s, %s, %s);",
               fixsql  (cexit_command),  -- direction (eg. "n")
               fixsql  (cexit_start),  -- from current room
               fixsql  (cexit_dest) -- destination room
            ))
            rooms[cexit_start].exits[cexit_command] = cexit_dest
            rooms[cexit_start].exit_locks[cexit_command] = "0"
            mapper.draw (current_room)
         else
            world.Note (string.format("CEXIT FAILED: Custom Exit %s leads back here!", cexit_command))
         end
      else
         world.Note ("CEXIT FAILED: Need to know where we ended up.")
      end
   end)
end -- custom_exit

function custom_fullexit (name, line, wildcards)

  local cexit_command = wildcards [1] or ""

  if cexit_command == "" then
     print("Nothing to do!")
     return
  end

  local cexit_start = wildcards[2]
  if not cexit_start then
    print("CEXIT FAILED: No start room provided.")
    return
  end

  if not load_room_from_database(cexit_start) then
    print("CEXIT FAILED: Room "..cexit_start.." is unknown.")
    return
  end

  local cexit_dest = wildcards[3]
  if not cexit_dest then
    print("CEXIT FAILED: No destination room provided.")
    return
  end

  if not load_room_from_database(cexit_dest) then
    print("CEXIT FAILED: Room "..cexit_dest.." is unknown.")
    return
  end

  local cexit_level = wildcards[4] and Trim(wildcards[4])
  if cexit_level == "" then
     cexit_level = "0"
  end

  if cexit_dest ~= cexit_start then
    world.Note (string.format("Custom Exit CONFIRMED: %s (%s) -> %s [lock level %s]", cexit_start, cexit_command, cexit_dest, cexit_level))
    dbCheckExecute(string.format("INSERT OR REPLACE INTO exits (dir, fromuid, touid, level) VALUES (%s, %s, %s, %s);",
      fixsql(cexit_command),
      fixsql(cexit_start),
      fixsql(cexit_dest),
      fixsql(cexit_level)
    ))
    rooms[cexit_start].exits[cexit_command] = cexit_dest
    rooms[cexit_start].exit_locks[cexit_command] = cexit_level
    mapper.draw (current_room)
  else
    print(string.format("CEXIT FAILED: Start room and destination room should be different."))
  end
end

last_area_requested = ""
function save_room_to_database (uid,room)

   assert (uid, "No UID supplied to save_room_to_database")
   ensure_area(room.area, (gmcproom and gmcproom.area == room.area and gmcproom.area_name) or room.area)

   dbCheckExecute(string.format (
         "INSERT OR REPLACE INTO rooms (uid, name, terrain, info, x, y, z, area, noportal, norecall, ignore_exits_mismatch) VALUES (%s, %s, %s, %s, %i, %i, %i, %s, %d, %d, %d);",
            fixsql (uid),
            fixsql (room.name),
            fixsql (room.terrain),
            fixsql (room.info),
            room.x or 0, room.y or 0, room.z or 0, fixsql(room.area),
            room.noportal or 0,
            room.norecall or 0,
            room.ignore_exits_mismatch and 1 or 0
         ))

   local exists = false
   for n in dbnrowsWRAPPER(string.format ("SELECT * FROM rooms_lookup WHERE uid = %s", fixsql(uid))) do
      exists = true
   end
   -- don't add multiple times, maintaining backwards database compatibility (there's no uniqueness constraint on rooms_lookup.uid)
   if not exists then
      dbCheckExecute(string.format ("INSERT INTO rooms_lookup (uid, name) VALUES (%s, %s);", fixsql(uid), fixsql(room.name)))
   else
      dbCheckExecute(string.format ("DELETE FROM rooms_lookup WHERE uid = %s",fixsql(uid)))
      dbCheckExecute(string.format ("INSERT INTO rooms_lookup (uid, name) VALUES (%s, %s);", fixsql(uid), fixsql(room.name)))
   end

   room_not_in_database [uid] = nil

   if show_database_mods then
      mapper.mapprint ("Added room", uid, "to database. Name:", room.name)
   end -- if
   return true
end -- function save_room_to_database

-- the game's areas come with every room (zone keyword and name): keep them
function ensure_area (zone, name)
   if zone == nil or zone == "" then
      return
   end
   if areas[zone] and areas[zone].name == name then
      return
   end
   dbCheckExecute (string.format (
      "INSERT OR REPLACE INTO areas (uid, name, texture, color, flags) VALUES (%s, %s, %s, %s, %s);",
      fixsql (zone), fixsql (name), fixsql (areas[zone] and areas[zone].texture or ""),
      fixsql (areas[zone] and areas[zone].color or ""), fixsql ("")
   ))
   areas[zone] = { name = name, texture = areas[zone] and areas[zone].texture or "",
                   color = areas[zone] and areas[zone].color or "", virtual = false }
end -- ensure_area

function fix_up_exit ()
   local room = rooms [from_room]

   dbCheckExecute(string.format ("UPDATE exits SET touid = %s WHERE fromuid = %s AND dir = %s;",
        fixsql  (current_room),     -- destination room
        fixsql  (from_room),       -- from previous room
        fixsql  (last_direction_moved)  -- direction (eg. "n")
   ))

--   if show_database_mods then
      mapper.mapprint ("Fixed exit", last_direction_moved, "from room", from_room, "to be to", current_room)
--   end -- if

   room.exits [last_direction_moved] = current_room

   last_direction_moved = nil
   from_room = nil
end -- fix_up_exit

function purgezone(zoneuid)
   local query = "BEGIN TRANSACTION;"
   query = query..string.format ("delete from exits where touid in (select uid from rooms where area = %s);",fixsql(zoneuid))
   query = query..string.format ("delete from exits where fromuid in (select uid from rooms where area = %s);",fixsql(zoneuid))
   query = query..string.format ("delete from rooms_lookup where uid in (select uid from rooms where area = %s);", fixsql(zoneuid))
   query = query..string.format ("delete from bookmarks where uid in (select uid from rooms where area = %s);", fixsql(zoneuid))
   query = query..string.format ("delete from rooms where area = %s;", fixsql(zoneuid))
   query = query..string.format ("delete from areas where uid = %s;", fixsql(zoneuid))
   query = query.."COMMIT;"
   dbCheckExecute(query)

   for k,v in pairs(rooms) do
      for j,u in pairs(v.exits) do
         if (rooms[u] ~= nil) and (rooms[u].area == zoneuid) then
            v.exits[j] = nil
            v.exit_locks[j] = nil
         end
      end
   end
   for k,v in pairs(rooms) do
      if v.area == zoneuid then
         rooms[k] = nil
      end
   end
   areas[zoneuid] = nil
   Send_GMCP_Packet("request room")
end

function map_purgezone (name, line, wildcards)
   local zoneuid = string.gsub(wildcards[1], "^%s*(.-)%s*$", "%1")
   local found = false
   local zone_name = ""
   if #(wildcards[1]) > 1 and string.sub(wildcards[1], 1, 1) == " " then
      for row in dbnrowsWRAPPER(string.format("SELECT uid,name FROM areas WHERE uid = %s;", fixsql (zoneuid))) do
         zone_name = row.name
         found = true
      end   -- finding area uid
   end

   if found == false then
      mapper.mapprint("Syntax: mapper purgezone <keyword>\nTry 'mapper areas' for a list of area keywords.\n")
      return
   end

   purgezone(zoneuid)

   mapper.mapprint ("Purged " .. zone_name .. " from the mapper database.")
end

function room_purgeroom (room, uid)
   map_purgeroom(uid)
   mapper.draw(current_room)
end

function map_purgeroom (uid, new_area)
   local query = "BEGIN TRANSACTION;"
   if new_area then
      query = query..string.format ("delete from exits where touid = %s and fromuid not in (select uid from rooms where area = %s);",fixsql(uid),fixsql(new_area))
   else
      query = query..string.format ("delete from exits where touid = %s;",fixsql(uid))
   end
   query = query..string.format ("delete from exits where fromuid = %s;",fixsql(uid))
   query = query..string.format ("delete from rooms_lookup where uid = %s;", fixsql(uid))
   query = query..string.format ("delete from bookmarks where uid = %s;", fixsql(uid))
   query = query..string.format ("delete from rooms where uid = %s;", fixsql(uid))
   query = query.."COMMIT;"
   dbCheckExecute(query)

   rooms[uid] = nil
   for k,v in pairs(rooms) do
      for j,u in pairs(v.exits) do
         if u == uid then
            v.exits[j] = nil
            v.exit_locks[j] = nil
         end
      end
   end
end

function map_clearcache()
   rooms = {}
   mapper.mapprint ("Cleared local room cache.")
end

function load_area_from_database (area)
   assert (area, "No area supplied to load_area_from_database")
   if not rooms then
      -- this shouldn't even be possible. what the hell.
      rooms = {}
   end

   local lastuid = -1
   local newrooms = {}

   for row in dbnrowsWRAPPER(string.format("SELECT rooms.*,exits.dir,exits.touid,exits.level FROM rooms,exits where rooms.area=%s and rooms.uid=exits.fromuid;", fixsql(area))) do
      if lastuid ~= row.uid then
         local fixstr = string.gsub(row.uid,"'","''")
         table.insert(newrooms, fixstr)
         rooms[row.uid] = {
         name = row.name,
         area = row.area,
         building = row.building,
         terrain = row.terrain,
         info = row.info,
         notes = row.notes,
         x = row.x or 0,
         y = row.y or 0,
         z = row.z or 0,
         noportal = row.noportal,
         norecall = row.norecall,
         exits = {[row.dir]=tostring(row.touid)},
         exit_locks = {[row.dir]=tostring(row.level)},
         ignore_exits_mismatch = (row.ignore_exits_mismatch == 1)
         }
      else
         rooms[row.uid].exits[row.dir] = tostring(row.touid)
         rooms[row.uid].exit_locks[row.dir] = tostring(row.level)
      end
      lastuid = row.uid
   end   -- finding room

   for bmrow in dbnrowsWRAPPER(string.format ("SELECT * FROM bookmarks WHERE uid in ('%s')", table.concat(newrooms,"','"))) do
      rooms[bmrow.uid].notes = bmrow.notes
   end   -- finding room

end -- load_area_from_database

function load_room_from_database (uid)
   local room
   local u = tostring(uid)
   assert (uid, "No UID supplied to load_room_from_database")

   -- if not in database, don't look again
   if room_not_in_database [u] then
      return nil
   end -- no point looking

   for row in dbnrowsWRAPPER(string.format ("SELECT * FROM rooms WHERE uid = %s", fixsql (u))) do
      room = {
         name = row.name,
         area = row.area,
         building = row.building,
         terrain = row.terrain,
         info = row.info,
         notes = row.notes,
         x = row.x or 0,
         y = row.y or 0,
         z = row.z or 0,
         noportal = row.noportal,
         norecall = row.norecall,
         exits = {},
         exit_locks = {},
         ignore_exits_mismatch = (row.ignore_exits_mismatch == 1)
      }

      for exitrow in dbnrowsWRAPPER(string.format ("SELECT * FROM exits WHERE fromuid = %s", fixsql (u))) do
         room.exits [exitrow.dir] = tostring (exitrow.touid)
         room.exit_locks [exitrow.dir] = tostring(exitrow.level)
      end -- for each exit

   end   -- finding room

   if room then
      if not rooms then
         -- this shouldn't even be possible. what the hell.
         rooms = {}
      end
      rooms [u] = room
      for row in dbnrowsWRAPPER(string.format ("SELECT * FROM bookmarks WHERE uid = %s", fixsql (u))) do
         rooms [u].notes = row.notes
      end   -- finding room

      return room
   end -- if found

   -- room not found in database
   room_not_in_database [u] = true
   return nil

end -- load_room_from_database

function create_tables ()
   -- create rooms table
   dbCheckExecute([[
   PRAGMA foreign_keys = ON;

   CREATE TABLE IF NOT EXISTS areas (
      areaid      INTEGER PRIMARY KEY AUTOINCREMENT,
      uid         TEXT    NOT NULL,   -- vnum or how the MUD identifies the area
      name        TEXT,               -- name of area
      date_added  DATE,               -- date added to database
      texture     TEXT,               -- background area texture
      color       TEXT,               -- ANSI colour code.
      flags       TEXT NOT NULL DEFAULT '',      -- area flags
      UNIQUE (uid)
   );
   CREATE INDEX IF NOT EXISTS areas_uid_index ON areas (uid);
   CREATE INDEX IF NOT EXISTS areas_name_index ON areas (name);

   CREATE TABLE IF NOT EXISTS environments (
      environmentid INTEGER PRIMARY KEY AUTOINCREMENT,
      uid           TEXT    NOT NULL,   -- code for the environment
      name          TEXT,               -- name of environment
      color         INTEGER,            -- ANSI colour code
      date_added    DATE,               -- date added to database
      UNIQUE (uid)
   );
   CREATE INDEX IF NOT EXISTS name_index ON environments (name);

   CREATE TABLE IF NOT EXISTS rooms (
      roomid        INTEGER PRIMARY KEY AUTOINCREMENT,
      uid           TEXT NOT NULL,   -- vnum or how the MUD identifies the room
      name          TEXT,            -- name of room
      area          TEXT,            -- which area
      building      TEXT,            -- which building it is in
      terrain       TEXT,            -- eg. road OR water
      info          TEXT,            -- eg. shop,healer
      notes         TEXT,            -- player notes
      x             INTEGER,
      y             INTEGER,
      z             INTEGER,
      date_added    DATE,            -- date added to database
      UNIQUE (uid),
      FOREIGN KEY(area) REFERENCES areas(uid)
   );
   CREATE INDEX IF NOT EXISTS info_index ON rooms (info);
   CREATE INDEX IF NOT EXISTS terrain_index ON rooms (terrain);
   CREATE INDEX IF NOT EXISTS area_index ON rooms (area);
   CREATE INDEX IF NOT EXISTS rname_index ON rooms (name);

   CREATE TABLE IF NOT EXISTS exits (
      dir         TEXT    NOT NULL, -- direction, eg. "n", "s"
      fromuid     TEXT    NOT NULL, -- exit from which room (in rooms table)
      touid       TEXT    NOT NULL, -- exit to which room (in rooms table)
      level       STRING  NOT NULL DEFAULT '0', -- minimum level to make use of this exit
      date_added  DATE,             -- date added to database
      PRIMARY KEY(fromuid, dir),
      FOREIGN KEY(fromuid) REFERENCES rooms(uid)
   );
   CREATE INDEX IF NOT EXISTS fromuid_index ON exits (fromuid);
   CREATE INDEX IF NOT EXISTS touid_index   ON exits (touid);
   ]])

   -- Since the MUD sends terrain as a string and not as an integer,
   -- it was wrong to originally produce rooms with integer terrains.
   -- Or maybe it's wrong for the MUD to send strings. Either way, we now
   -- have databases with inconsistent data. So let's make it consistent.
   dbCheckExecute("UPDATE OR IGNORE rooms SET terrain = ifnull((SELECT name FROM environments WHERE environments.uid = rooms.terrain), rooms.terrain);")

   -- check if rooms_lookup table exists
   dbCheckExecute([[
   BEGIN TRANSACTION;
   DROP TABLE IF EXISTS rooms_lookup;
   CREATE VIRTUAL TABLE rooms_lookup USING FTS3(uid, name);
   INSERT INTO rooms_lookup (uid, name) SELECT uid, name FROM rooms;
   COMMIT;
   ]])
end -- function create_tables

function check_rooms_flags()
   local flag_exists = { noportal = false,
                         norecall = false }
   for a in dbnrowsWRAPPER "PRAGMA table_info('rooms')" do
      if flag_exists[a.name] ~= nil then
         flag_exists[a.name] = true
      end
   end

   for k, v in pairs(flag_exists) do
      if not v then
         local sql = 'ALTER TABLE rooms ADD ' .. k .. ' INTEGER'
         dbCheckExecute(sql)
      end
   end
end -- function check_rooms_flags

function get_room (uid)

   -- look it up
   local ourroom = rooms [uid]

   -- not cached - see if in database
   if not ourroom then
      ourroom = load_room_from_database (uid)
      rooms [uid] = ourroom -- cache for later

      if not ourroom then
         return nil
      end -- if

      load_area_from_database(ourroom.area)
   end -- not in cache

   local room = copytable.deep(ourroom)
   room.bordercolour = mapper.ROOM_COLOUR.colour
   if areas[room.area] then
      room.texture = areas[room.area].texture
      if areas[room.area].color ~= "" then
         room.bordercolour = areas[room.area].color or mapper.ROOM_COLOUR.colour
      end
      room.area = areas[room.area].name or string.format ("%s", room.area or "<unknown>")
   end

   if uid == current_room then
      current_area = room.area
   end -- if

   -- build hover message
   local environmentname = room.terrain
   if tonumber (environmentname) then
      environmentname = environments [tonumber (environmentname)]
   end -- convert to name

   local terrain = ""
   if environmentname and environmentname ~= "" then
      terrain = "\nTerrain: " .. capitalize (environmentname)
   end -- if terrain known

   local info = ""
   if room.info and room.info ~= "" then
      info = "\nInfo: " .. capitalize (room.info)
   end -- if info known

   local notes = ""
   if room.notes and room.notes ~= "" then
      notes = "\nNote: " .. room.notes
   end -- if notes

   local flags = ""
   if room.norecall == 1 then
         flags = flags.."norecall "
   end
   if room.noportal == 1 then
         flags = flags.."noportal"
   end
   if flags ~= "" then
      flags = "\nFlags: "..string.gsub(flags," ",", ")
   end

   local texits = {}
   for dir in pairs (room.exits) do
      table.insert (texits, dir)
   end -- for
   table.sort (texits)

   local areaname = room.area
   if tonumber (areaname) then
      areaname = areas [tonumber (areaname)].name
   end -- convert to name

   room.hovermessage = string.format (
      "%s\tExits: %s\nRoom: %s\nArea: %s%s%s%s%s",
      room.name,
      table.concat (texits, ", "),
      uid,
      areaname,
      terrain,
      info,
      notes,
      flags
      -- depth,
      -- table.concat (path, ",")
      )

   room.borderpen = 0 -- solid
   room.borderpenwidth = 1
   room.fillcolour = 0xff0000
   room.fillbrush = 1 -- no fill
   -- special room fill colours
   local special_room = false
   if room.info and room.info ~= "" then
      if string.match (room.info, "shop") then
         special_room = true
         room.fillcolour = mapper.SHOP_FILL_COLOUR.colour
         room.fillbrush = 0  -- solid
      elseif string.match (room.info, "healer") then
         special_room = true
         room.fillcolour = mapper.HEALER_FILL_COLOUR.colour
         room.fillbrush = 0  -- solid
      elseif string.match (room.info, "guild") then
         special_room = true
         room.fillcolour = mapper.GUILD_FILL_COLOUR.colour
         room.fillbrush = 0 -- solid
      elseif string.match (room.info, "trainer") then
         special_room = true
         room.fillcolour = mapper.TRAINER_FILL_COLOUR.colour
         room.fillbrush = 0 -- solid
      elseif string.match (room.info, "questor") then
         special_room = true
         room.fillcolour = mapper.QUESTOR_FILL_COLOUR.colour
         room.fillbrush = 0 -- solid
      elseif string.match (room.info, "bank") then
         special_room = true
         room.fillcolour = mapper.BANK_FILL_COLOUR.colour
         room.fillbrush = 0  -- solid
      elseif string.match (room.info, "safe") then
         special_room = true
         room.fillcolour = mapper.SAFEROOM_FILL_COLOUR.colour
         room.fillbrush = 0  -- solid
      end -- if
   end
   -- use terrain colour
   if environmentname and environmentname ~= "" and not special_room then
      if user_terrain_colour [environmentname] then
         room.fillcolour = user_terrain_colour [environmentname]
         room.fillbrush = 8  -- fine pattern
      elseif terrain_colours [environmentname] then
         room.fillcolour = colour_lookup [terrain_colours [environmentname]]
         room.fillbrush = 8  -- fine pattern
      end
   end -- if environmentname

   if uid == current_room then
      room.bordercolour = mapper.OUR_ROOM_COLOUR.colour
      room.borderpenwidth = 3
   elseif room.area ~= current_area then
      room.bordercolour = mapper.DIFFERENT_AREA_COLOUR.colour
   elseif room.info and string.match(room.info, "pk") then
      room.bordercolour = mapper.PK_BORDER_COLOUR.colour
      room.borderpenwidth = 3
   end -- not in this area

   return room

end -- get_room

function room_delete_note (uid, prev_notes)
   dbCheckExecute(string.format(
      "DELETE FROM bookmarks WHERE uid = %s;",
      fixsql(uid)
   ))
   mapper.mapprint("Note for room", uid, "deleted. Was previously:", prev_notes)
   rooms[uid].notes = nil
   mapper.draw(current_room)
end

function room_find_notes (uid)
   for row in dbnrowsWRAPPER(string.format("SELECT * FROM bookmarks WHERE uid = %s", fixsql(uid))) do
      return row.notes, true
   end
end

function room_cexits (uid)
   if uid == nil then
      if current_room ~= nil then
         uid = current_room
      else
         return nil
      end
   end

   local room = rooms[uid]
   if room == nil then
      room = load_room_from_database(uid)
   end

   if room then
      local custom_exits = {}
      for k,v in pairs(room.exits) do
         if #k > 1 then
            custom_exits[k] = v
         end
      end
      return serialize.save_simple(custom_exits)
   end
end

function room_edit_note (room, uid, newnotes)

   if uid == nil and current_room ~= nil then
      uid = current_room
      room = rooms[current_room]
   end

   if uid == nil then -- still nothing?
      print("No room received from the mud yet. Try using the 'LOOK' command first.")
      return
   end

   local notes, found = room_find_notes(uid)

   if newnotes == nil or newnotes == "" then
      if found then
         newnotes = utils.inputbox ("Modify room comment (clear it to delete from database)", room.name, notes)
      else
         newnotes = utils.inputbox ("Enter room comment (creates a note for this room)", room.name, notes)
      end -- if
   end

   if not newnotes then
      return
   end -- if cancelled

   if newnotes == "" then
      if not found then
         mapper.mapprint ("No comment entered, note not saved.")
         return
      else
         room_delete_note(uid, notes)
         return
      end -- if
   end -- if

   if notes == newnotes then
      return -- no change made
   end -- if

   if found then
      dbCheckExecute(string.format (
      "UPDATE bookmarks SET notes = %s WHERE uid = %s;",
      fixsql (newnotes),
      fixsql (uid)
      ))
      mapper.mapprint ("Note for room", uid, "changed to:", newnotes)
   else
      dbCheckExecute(string.format (
      "INSERT INTO bookmarks (uid, notes) VALUES (%s, %s);",
      fixsql (uid),
      fixsql (newnotes)
      ))
      mapper.mapprint ("Note added to room", uid, ":", newnotes)
   end -- if

   rooms [uid].notes = newnotes
   mapper.draw (current_room)

end -- room_edit_note

function room_edit_terrain_colour (room, uid)

   if not room.terrain then
      utils.msgbox ("This room does not have a terrain type", "Unknown terrain!", "ok", "!", 1)
      return
   end -- not known

   local environmentname = room.terrain
   if tonumber (environmentname) then
      environmentname = environments [tonumber (environmentname)]
   end -- convert to name

   local colour
   local colourtype = terrain_colours [environmentname]

   if colourtype then
      colour = colour_lookup [colourtype]
   end -- if type known

   if user_terrain_colour [environmentname] then
      colour = user_terrain_colour [environmentname]
   end -- if already have user colour

   local newcolour = PickColour (colour or 0x000000)
   if newcolour == -1 or newcolour == colour then
      return
   end -- cancelled

   if user_terrain_colour [environmentname] then
      dbCheckExecute(string.format (
      "UPDATE terrain SET color = %s WHERE name = %s;",
      fixsql (newcolour),
      fixsql (environmentname)
      ))
      mapper.mapprint ("Colour for terrain '" .. environmentname .. "' changed to:", RGBColourToName (newcolour))
   else
      dbCheckExecute(string.format (
      "INSERT INTO terrain (name, color) VALUES (%s, %s);",
      fixsql (environmentname),
      fixsql (newcolour)
      ))
      mapper.mapprint ("Colour for terrain '" .. environmentname .. "' is now", RGBColourToName (newcolour))
   end -- if

   user_terrain_colour [environmentname] = newcolour

   mapper.draw (current_room)

end -- room_edit_terrain_colour


function room_delete_exit (room, uid, exit)
   local chosen_exit = exit

   if chosen_exit == nil then
      local available =  {}  -- end of available

      -- construct available exits list for display
      for k,v in pairs (room.exits) do
         available [k] = k .. " --> " .. room.exits [k]
      end -- for

      if next (available) == nil then
         utils.msgbox ("There are no exits from this room.", "No exits!", "ok", "!", 1)
         return
      end -- not known

      local chosen_exit = utils.listbox ("Choose exit to delete", "Exits ...", available )
      if not chosen_exit then
         return
      end
   end

   dbCheckExecute(string.format ([[
      DELETE FROM exits WHERE dir = %s AND fromuid = %s;
      ]],
      fixsql (chosen_exit),  -- direction (eg. "n")
      fixsql (uid)  -- from current room
   ))
   mapper.mapprint ("Deleted exit '"..chosen_exit.."' from room "..uid.." from database.")

   -- update in-memory table
   rooms[uid] = load_room_from_database(uid)

   mapper.draw (current_room)

end -- room_delete_exit


function room_change_exit (room, uid, exit)
   local chosen_exit = exit

   if chosen_exit == nil then
      local available =  {}  -- end of available

      -- construct available exits list for display
      for k,v in pairs (room.exits) do
         available [k] = k .. " --> " .. room.exits [k]
      end -- for

      if next (available) == nil then
         utils.msgbox ("There are no exits from this room.", "No exits!", "ok", "!", 1)
         return
      end -- not known

      local chosen_exit = utils.listbox ("Choose exit to change destination of:", "Exits ...", available )
      if not chosen_exit then
         return
      end
   end

   exit_destination = utils.inputbox ("Enter destination room identifier (number) for " .. chosen_exit, room.name, room.exits[chosen_exit])

   if not exit_destination then
      return
   end -- cancelled

   -- look it up
   local dest_room = rooms [exit_destination]

   -- not cached - see if in database
   if not dest_room then
      dest_room = load_room_from_database (exit_destination)
      rooms [exit_destination] = dest_room -- cache for later
   end -- not in cache

   if not dest_room then
      utils.msgbox ("Room " .. exit_destination .. " does not exist.", "Room does not exist!", "ok", "!", 1)
      return
   end -- if still not there

   if exit_destination ~= room.exits[chosen_exit] then
      dbCheckExecute(string.format ([[
         UPDATE exits SET touid = %s WHERE dir = %s AND fromuid = %s;
         ]],
         fixsql (exit_destination),
         fixsql (chosen_exit),  -- direction (eg. "n")
         fixsql (uid)  -- from current room
      ))

      mapper.mapprint ("Modified exit", chosen_exit, "from room", uid, "to be to room", exit_destination, "in database.")

      -- update in-memory table
      rooms [uid].exits [chosen_exit] = exit_destination
      mapper.draw (current_room)
   else
      mapper.mapprint ("Destination unchanged for exit '"..chosen_exit.."'.")
   end

end -- room_change_exit

function room_change_cexit_command (room, uid, exit)
   local chosen_exit = exit
   local to = room.exits[exit]

   local new_cexit = utils.inputbox ("Enter new custom exit command to replace '" .. chosen_exit.."' from "..uid.." to "..to, "New Custom Exit Command", chosen_exit)
   if new_cexit then
      new_cexit = Trim(new_cexit)
   end
   if (not new_cexit) or (new_cexit == "") or (new_cexit == chosen_exit) then
      mapper.mapprint ("Custom exit command not changed.")
   else
      dbCheckExecute(string.format ([[
         UPDATE exits SET dir = %s WHERE fromuid = %s AND dir = %s;
         ]],
         fixsql (new_cexit),
         fixsql (uid),  -- from current room
         fixsql (chosen_exit)  -- direction (eg. "n")
      ))

      mapper.mapprint ("New custom exit from room "..uid.." to "..to..": '"..new_cexit.."'")

      -- update in-memory table
      rooms[uid].exits[new_cexit] = rooms[uid].exits[chosen_exit]
      rooms[uid].exit_locks[new_cexit] = rooms[uid].exit_locks[chosen_exit]
      rooms[uid].exits[chosen_exit] = nil
      rooms[uid].exit_locks[chosen_exit] = nil

      mapper.draw (current_room)
   end
end -- room_change_cexit_command



function room_lock_exit (room, uid, exit)
   if uid == nil and current_room ~= nil then
      uid = current_room
      room = rooms[uid]
   end

   if uid == nil then -- still nothing? abort!
      print("No room received from the mud yet. Try using the 'LOOK' command first.")
      return
   end

   -- construct available exits list for display
   local available = {}
   for k,v in pairs (room.exits) do
      available [k] = k .. " --> " .. room.exits [k]
   end -- for

   local chosen_exit = exit
   if chosen_exit == nil then
      if next (available) == nil then
         utils.msgbox ("There are no exits from this room.", "No exits!", "ok", "!", 1)
         return
      end -- not known

      chosen_exit = utils.listbox ("Choose exit to lock:", "Exits ...", available )
      if not chosen_exit then
         return
      end -- cancelled
   end

   exit_level = tostring(tonumber(utils.inputbox ("Enter level lock for " .. chosen_exit, room.name, room.exit_locks[chosen_exit],nil,nil,{validate=positive_integer_check}))) -- use tonumber to strip off any ".0"

   if exit_level == "nil" then
      return
   end -- cancelled

   if not rooms [uid].exit_locks then
      rooms [uid].exit_locks = {}
   end

   if exit_level ~= rooms[uid].exit_locks[chosen_exit] then
      dbCheckExecute(string.format ([[
         UPDATE exits SET level = %s WHERE dir = %s AND fromuid = %s;
         ]],
         fixsql (exit_level),
         fixsql (chosen_exit), -- direction (eg. "n")
         fixsql (uid) -- from current room
      ))

      mapper.mapprint ("Set level lock on exit", available [chosen_exit], "from room", uid, "to be", exit_level, "in database.")
   else
      mapper.mapprint ("Level lock unchanged for exit '"..chosen_exit.."'.")
   end

   -- update in-memory table
   rooms[uid].exit_locks[chosen_exit] = exit_level
   mapper.draw (current_room)

end -- room_lock_exit

function room_click (uid, flags)

   -- check we got room at all
   if not uid then
      return nil
   end -- if

   -- look it up
   local room = rooms [uid]

   -- not cached - see if in database
   if not room then
      room = load_room_from_database (uid)
      rooms [uid] = room -- cache for later
   end -- not in cache

   if not room then
      return
   end -- if still not there

   local handlers = {}
   table.insert(handlers, { name = "Edit Note", func = room_edit_note})
   table.insert(handlers, { name = "Edit Terrain Colour", func = room_edit_terrain_colour})
   table.insert(handlers, { name = "-", })

   table.insert(handlers, { name = ">Change Exit Destination", })
   for ex, to in pairs(room.exits) do
      table.insert(handlers, { name = ex:gsub("|", "/").." -> "..to, func = room_change_exit, param = ex})
   end
   table.insert(handlers, { name = "<", })

   table.insert(handlers, {name = ">Change Exit Level Lock", })
   for ex, lock in pairs(room.exit_locks) do
      table.insert(handlers, { name = ex:gsub("|", "/").."  [ "..lock.." ]", func = room_lock_exit, param = ex})
   end
   table.insert(handlers, { name = "<", })

   local has_cexits = false
   for ex, to in pairs(room.exits) do
      if not string.find("n s e w u d", ex) then
         has_cexits = true
         break
      end
   end
   if has_cexits then
      table.insert(handlers, { name = ">Change Custom Exit Command", })
      for ex, to in pairs(room.exits) do
         if not string.find("n s e w u d", ex) then
            table.insert(handlers, { name = ex:gsub("|", "/").." -> "..to, func = room_change_cexit_command, param = ex})
         end
      end
      table.insert(handlers, { name = "<", })
   end
   table.insert(handlers, { name = "-", })
   table.insert(handlers, { name = (room.ignore_exits_mismatch and "+" or "").."Ignore Exits Mismatch Here", func = ignore_mismatch_toggle, param = room.ignore_exits_mismatch})
   table.insert(handlers, { name = ">Delete Exit", })
   for ex, to in pairs(room.exits) do
      table.insert(handlers, { name = ex:gsub("|", "/").." -> "..to, func = room_delete_exit, param = ex})
   end
   table.insert(handlers, { name = "<", })

   table.insert(handlers, { name = "Delete Room", func = room_purgeroom})

   local t, tf = {}, {}
   for i, v in ipairs(handlers) do
      table.insert (t, v.name)
      if v.func then
         table.insert(tf, v)
      end
   end -- for

   local choice = WindowMenu (mapper.win,
                        WindowInfo (mapper.win, 14),
                        WindowInfo (mapper.win, 15),
                        "!"..table.concat (t, "|"))

   if choice ~= "" then
      local f = tf[tonumber(choice)]
      if f then
         f.func(room, uid, f.param)
      end -- if handler found
   end
end -- room_click

auto_backup_count = tonumber(GetVariable("auto_backup_count")) or 0
function rotate_backups (manual)
   if (manual) then
      os.remove(firstBackup.."_Manual_oldest")
      os.remove(firstBackup.."_Manual_oldest.zip")
      os.rename(firstBackup.."_Manual_older", firstBackup.."_Manual_oldest")
      os.rename(firstBackup.."_Manual_older.zip", firstBackup.."_Manual_oldest.zip")
      os.rename(firstBackup.."_Manual_old", firstBackup.."_Manual_older")
      os.rename(firstBackup.."_Manual_old.zip", firstBackup.."_Manual_older.zip")
      os.rename(firstBackup.."_Manual", firstBackup.."_Manual_old")
      os.rename(firstBackup.."_Manual.zip", firstBackup.."_Manual_old.zip")
      ok, err = os.rename(firstBackup, firstBackup.."_Manual")
      ok, err = os.rename(firstBackup..".zip", firstBackup.."_Manual.zip")
   else
      auto_backup_count = auto_backup_count + 1
      local t = utils.readdir (firstBackup.."_Automatic*")
      local oldest_exists = false
      local olderer_exists = false
      local older_exists = false
      if t ~= nil then
         local oldest_string = db_name..".db.Backup_Automatic_oldest"
         local olderer_string = db_name..".db.Backup_Automatic_olderer"
         local older_string = db_name..".db.Backup_Automatic_older"
         for k,v in pairs(t) do
            if string.find(k,oldest_string) then
               oldest_exists = true
            elseif string.find(k,olderer_string) then
               olderer_exists = true
            elseif string.find(k,older_string) then
               older_exists = true
            end
         end
      end
      if not (oldest_exists and olderer_exists and older_exists) then
         auto_backup_count = 1
      end
      if oldest_exists == false or auto_backup_count == 84 then
         os.remove(firstBackup.."_Automatic_oldest")
         os.remove(firstBackup.."_Automatic_oldest.zip")
         os.rename(firstBackup.."_Automatic_olderer", firstBackup.."_Automatic_oldest")
         os.rename(firstBackup.."_Automatic_olderer.zip", firstBackup.."_Automatic_oldest.zip")
      end
      if oldest_exists == false or olderer_exists == false or auto_backup_count % 28 == 0 then
         os.remove(firstBackup.."_Automatic_olderer")
         os.remove(firstBackup.."_Automatic_olderer.zip")
         os.rename(firstBackup.."_Automatic_older", firstBackup.."_Automatic_olderer")
         os.rename(firstBackup.."_Automatic_older.zip", firstBackup.."_Automatic_olderer.zip")
      end
      if oldest_exists == false or olderer_exists == false or older_exists == false or auto_backup_count % 7 == 0 then
         os.remove(firstBackup.."_Automatic_older")
         os.remove(firstBackup.."_Automatic_older.zip")
         os.rename(firstBackup.."_Automatic_old", firstBackup.."_Automatic_older")
         os.rename(firstBackup.."_Automatic_old.zip", firstBackup.."_Automatic_older.zip")
      end
      os.remove(firstBackup.."_Automatic_old")
      os.remove(firstBackup.."_Automatic_old.zip")
      os.rename(firstBackup.."_Automatic", firstBackup.."_Automatic_old")
      os.rename(firstBackup.."_Automatic.zip", firstBackup.."_Automatic_old.zip")
      os.rename(firstBackup, firstBackup.."_Automatic")
      os.rename(firstBackup..".zip", firstBackup.."_Automatic.zip")
      if auto_backup_count == 84 then
         auto_backup_count = 1
      end

      SetVariable("auto_backup_count", auto_backup_count)
      SetVariable("last_auto_backup", os.time())
   end
end

function automatic_backup()
   if fighting_state == true then
      AddTimer("backup_timer", 0, 0, 5, "", timer_flag.Replace + timer_flag.Enabled + timer_flag.ActiveWhenClosed, "automatic_backup")
      maybe_Note("Mapper database automatic backup delayed due to combat. Will try again in 5 seconds.")
   else
      AddTimer("backup_timer", 23, 59, 59, "", timer_flag.Replace + timer_flag.Enabled + timer_flag.ActiveWhenClosed, "automatic_backup")
      backup_databases(false)
   end
end

function manual_backup()
   backup_databases(true)
end

function sanitize_filename(str)
   str = string.gsub(str, "[^%w%s()_-]", "")
   return Trim(str)
end

function quote(str)
   return "\""..str.."\""
end

function checkDatabaseIntegrity(full_check, second_run)
   Repaint()
   -- If needed, force wal_checkpoint databases to make sure everything gets written out
   -- this is a harmless no-op if not using journal_mode=WAL
   dbCheckExecute("PRAGMA wal_checkpoint(FULL);")
   local integrityCheck = true
   local integrity_check_pragma = "quick_check"
   if full_check then
      integrity_check_pragma = "integrity_check"
   end
   for row in dbnrowsWRAPPER("PRAGMA "..integrity_check_pragma..";") do
      if row[integrity_check_pragma] ~= "ok" then
         tprint(row)
         integrityCheck = false
      end
   end
   if integrityCheck == false and second_run ~= true then
      ColourNote("yellow", "red", "MAPPER DATABASE INTEGRITY CHECK FAILED. THIS IS A SIGN OF DATABASE CORRUPTION. REBUILDING INDEXES AND TRYING AGAIN.")
      CorruptionAlert()
      dbCheckExecute("REINDEX")
      return checkDatabaseIntegrity(true, true)
   end
   if not integrityCheck then
      ColourNote("yellow", "red", "FAILED SECOND INTEGRITY CHECK. CLOSE MUSHCLIENT AND RESTORE A KNOWN GOOD DATABASE BACKUP IMMEDIATELY.")
      utils.msgbox("FAILED MAPPER DATABASE INTEGRITY CHECK. CLOSE MUSHCLIENT AND RESTORE A KNOWN GOOD DATABASE BACKUP IMMEDIATELY.", "Error!", "ok", "!", 1)
      return false
   end
   if second_run then
      ColourNote("white", "blue", "MAP DATABASE INTEGRITY CHECK PASSED! LUCKY!")
   end
   return true
end

function maybe_Note(msg, force)
   if (not quiet_backups) or force then
      Note(msg)
   end
end

function backup_databases(manual)
   wait.make(function()

      performing_maintenance = true
      local success = false
      maybe_Note("PERFORMING "..((manual and "MANUAL") or "AUTOMATIC").." DATABASE BACKUP. DON'T TOUCH ANYTHING!", manual)
      if not checkDatabaseIntegrity(true) then
         maybe_Note("ABORTING CURRENT BACKUP", manual)
         if not compact_mode then Note("") end
         Repaint()
         db = assert (sqlite3.open(worldPath..".db")) -- try to re-open the database anyway
         performing_maintenance = false
         satisfy_broadcast_queue()
         return
      end
      maybe_Note("BACKING UP DATABASE", manual)
      Repaint()

      backupPath = GetInfo(66).."db_backups\\"..db_name
      firstBackup = backupPath..".db.Backup"
      local ffi
      if type(jit) == 'table' then
         ffi = require("ffi")
         ffi.cdef[[
         bool CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);
         bool CopyFileA(const char* lpExistingFileName, const char * lpNewFileName, bool bFailIfExists);
         unsigned long GetLastError(void);
         ]]

         succ = ffi.C.CreateDirectoryA(GetInfo(66).."db_backups\\", nil)
         err_no = ffi.C.GetLastError()
      else
         succ, err_no = utils.shellexecute("cmd", "/C mkdir db_backups", GetInfo(66), "open", 0)
      end
      if succ == false and err_no ~= 183 and err_no ~= 127 then
         -- error
         ColourNote("yellow","red", "ERROR ("..err_no..") trying to CreateDirectory: "..GetInfo(66).."db_backups\\\n")
      else
         -- successfully created the backup directory

         -- record when the backup occurs
         dbCheckExecute(string.format("INSERT OR REPLACE INTO storage (name, data) VALUES (%s,%s);", fixsql("backup_date"), fixsql(os.date("%b %d, %Y - %X"))))

         if db:isopen() then
            db:close() -- always close the database before copying
         end

         -- make new backup
         if use_compression == 1 then

            local tmp = GetInfo(66).."mim_mapper_temp_file.txt" -- temp file for recording output
            -- It's not so simple to catch errors from this, so record the output and look at it later.
            -- Use pushd/popd because cd can't @!#($$# access UNC paths.
            local execute_string = "pushd "..quote(GetInfo(66)).." & zip -j -v -T -lf "..quote(tmp).." "..quote(firstBackup..".zip").." "..quote(worldPath..".db").." & popd"

            function file_exists(name)
               local f=io.open(name,"r")
               if f~=nil then io.close(f) return true else return false end
            end
            function file_is_writable(name)
               local f=io.open(name,"w")
               if f~=nil then io.close(f) return true else return false end
            end

            if file_exists(tmp) and not file_is_writable(tmp) then
               ColourNote("yellow","red", "ERROR executing system command: "..execute_string.."\n")
               ColourNote("yellow", "red", "Log file "..quote(tmp).." is not writeable. Do you have write permission?") -- flaming output
               -- failure
            else
               utils.shellexecute("cmd", "/C "..execute_string, "", "open", 0)

               local zip_done = false
               local zip_error = false
               local zipstarttime = utils.timer()
               local zip_lines
               while not zip_done do
                  zip_lines = {}
                  if file_exists(tmp) then
                     for line in io.lines(tmp) do
                        table.insert(zip_lines, line)
                     end
                     os.remove(tmp) -- remove temp file
                  end

                  for _,line in ipairs(zip_lines) do
                     if line:find("zip warning") or line:find("zip error") or line:find("zip I/O error") then
                        zip_done = true
                        zip_error = true
                     end
                     if line:find("zip OK") then
                        zip_done = true
                     end
                  end

                  if zip_done then break end
                  if utils.timer() - zipstarttime > 30 then
                     -- we have to give up EVENTUALLY
                     break
                  end
                  wait.time(0.1)
               end

               if zip_error then
                  -- something went wrong with the compression
                  -- we expect to see some lines with the last line showing a passing test
                  ColourNote("yellow","red", "ERROR executing system command: "..execute_string.."\n")
                  for _,v in ipairs(zip_lines) do
                     ColourNote("yellow", "red", v) -- flaming output
                  end
                  -- failure
               else
                  for _,v in ipairs(zip_lines) do
                     maybe_Note(v, manual) -- less intense output
                  end
                  success = true
               end
            end
         else
            -- no compression, just copy
            if type(jit) == 'table' then
               succ = ffi.C.CopyFileA(worldPath..".db", firstBackup, false)
               err_no = ffi.C.GetLastError()
            else
               -- succ, err_no =  utils.shellexecute("cmd", "/C copy "..worldPath..".db "..firstBackup, "", "open", 0);
               local inp, outp, errmsg
               succ = false
               inp, errmsg, err_no = io.open(worldPath..".db", "rb")
               if err_no == nil then
                  outp, errmsg, err_no = io.open(firstBackup, "wb")
                  if err_no == nil then
                     local data = inp:read("*all")
                     outp:write(data)
                     outp:close()
                     inp:close()
                     succ = true
                  end
               end
               if succ == false then
                  ColourNote("yellow","red", "ERROR: "..errmsg)
               end
            end
            if succ == false then
               ColourNote("yellow","red", "ERROR ("..err_no..") trying to copy database from '"..worldPath..".db' to '"..firstBackup.."'")
            else
               success = true
            end
         end
      end

      if success then
         rotate_backups(manual) -- rotate backup folders (obviously)
         maybe_Note("FINISHED DATABASE BACKUP. YOU MAY NOW GO BACK TO MUDDING.", manual)
      else
         Note("ABORTING BACKUP.")
         CorruptionAlert()
      end
      SaveState()

      if not compact_mode then Note("") end
      db = assert (sqlite3.open(worldPath..".db")) -- re-open database
      Repaint()
      performing_maintenance = false
      satisfy_broadcast_queue()
   end)
end

function CorruptionAlert()
   if not compact_mode then Note("") end
   Note("The mapper database may be damaged. Restore a backup from MUSHclient's db_backups folder (mapper backup makes one).")
   if not compact_mode then Note("") end
end

-- ec, areaKeys = CallPlugin("b6eae87ccedd84f510b74714", "getkeyword", "The Broken Halls of Horath") puts "horath" into areaKeys
-- ec, areaKeys = CallPlugin("b6eae87ccedd84f510b74714", "getkeyword", "halls") puts "damned,horath,vanir" into areaKeys
function getkeyword(area)
   local keys = {}
   for row in dbnrowsWRAPPER(string.format("SELECT uid FROM areas WHERE name LIKE %s;", fixsql("%"..area.."%"))) do
      table.insert(keys,row.uid);
   end -- finding areas
   return table.concat(keys, ",")
end

function fix_exits_table_v7()
   -- The original database implementation for exits was naive and broken. We want to fix it up here.
   -- Unfortunately, sqlite requires a little dance of table dropping for this to happen.
   local level_column_exists = false
   for a in dbnrowsWRAPPER "PRAGMA table_info('exits')" do
      if a["name"] == "level" then
         level_column_exists = true
      end
   end

   local query = [[
   BEGIN TRANSACTION;

   ALTER TABLE exits RENAME TO exits_backup;

   CREATE TABLE exits (
      dir         TEXT    NOT NULL, -- direction, eg. "n", "s"
      fromuid     TEXT    NOT NULL, -- exit from which room (in rooms table)
      touid       TEXT    NOT NULL, -- exit to which room (in rooms table)
      level       STRING  NOT NULL DEFAULT '0', -- minimum level to make use of this exit
      date_added  DATE,             -- date added to database
      PRIMARY KEY(fromuid, dir),
      FOREIGN KEY(fromuid) REFERENCES rooms(uid)
   );

   INSERT OR REPLACE INTO exits(dir,fromuid,touid,date_added]]..(((level_column_exists == false) and "") or ",level")..[[) SELECT dir, fromuid, touid, date_added]]..(((level_column_exists == false) and "") or ", level")..[[ FROM exits_backup;
   DROP TABLE exits_backup;
   CREATE INDEX IF NOT EXISTS fromuid_index ON exits (fromuid);
   CREATE INDEX IF NOT EXISTS touid_index   ON exits (touid);
   COMMIT;
   ]]
   dbCheckExecute(query)
end

function remove_date_added_columns()
   print("---------------------------------------------------------")
   print("Removing date_added from exits table...")
   Repaint()
   drop_db_columns("exits", {"date_added"}, "PRIMARY KEY(fromuid, dir)")
   print("Removing date_added from rooms table...")
   Repaint()
   drop_db_columns("rooms", {"date_added", "roomid"}, "PRIMARY KEY(uid)")
   print("Removing date_added from areas table...")
   Repaint()
   drop_db_columns("areas", {"date_added", "areaid"}, "PRIMARY KEY(uid)")
   print("Removing date_added from bookmarks table...")
   Repaint()
   drop_db_columns("bookmarks", {"date_added", "id"}, "PRIMARY KEY(uid)")
   print("Removing date_added from environments table...")
   Repaint()
   drop_db_columns("environments", {"date_added", "environmentid"}, "PRIMARY KEY(uid)")
   print("---------------------------------------------------------")
   Repaint()
end

function drop_db_columns(db_table, drop_columns, extra_directives)
   local drop_columns_lookup = {}
   for _,v in ipairs(drop_columns) do
      drop_columns_lookup[v] = true
   end

   local columns_to_keep = {}
   local column_names = {}
   for a in dbnrowsWRAPPER("PRAGMA table_info('"..db_table.."')") do
      if drop_columns_lookup[a["name"]] == nil then
         table.insert(columns_to_keep, a)
         table.insert(column_names, a["name"])
      end
   end
   local columns_string = table.concat(column_names, ",")

   local query = "BEGIN TRANSACTION;\n"
   query = query.."CREATE TEMPORARY TABLE t1_backup(\n"
   for i,col in ipairs(columns_to_keep) do
      query = query..col["name"].." "..col["type"]..(col["notnull"] == 1 and " NOT NULL" or "")..(col["dflt_value"] ~= nil and (" DEFAULT "..col["dflt_value"]) or "")..",\n"
   end
   query = query..extra_directives..");\n"
   query = query.."INSERT INTO t1_backup SELECT "..columns_string.." FROM "..db_table..";\n"
   query = query.."DROP TABLE "..db_table..";\n"
   query = query.."CREATE TABLE "..db_table.."(\n"
   for i,col in ipairs(columns_to_keep) do
      query = query..col["name"].." "..col["type"]..(col["notnull"] == 1 and " NOT NULL" or "")..(col["dflt_value"] ~= nil and (" DEFAULT "..col["dflt_value"]) or "")..",\n"
   end
   query = query..extra_directives..");\n"
   query = query.."INSERT INTO "..db_table.." SELECT "..columns_string.." FROM t1_backup;\n"
   query = query.."DROP TABLE t1_backup;\n"
   query = query.."COMMIT;"

   dbCheckExecute(query)
end

function set_database (new_db_name)
   if new_db_name ~= "" then
      new_db_name = new_db_name:gsub("%.db$", "", 1)
      print("Setting mapper database to "..new_db_name..".db")
      SetVariable("db_name", new_db_name)
      OnPluginInstall()
   end
end

function get_database ()
   print("Current mapper database file is "..GetVariable("db_name")..".db")
end

function OnPluginInstall ()
   SetVariable("backup_timer_enabled", GetVariable("backup_timer_enabled") or "1")
   quiet_backups = (GetVariable("quiet_backups") == "1") -- default false

   SetOption ("tool_tip_visible_time", 10000) -- prolong the tooltip display

   configs = {}  -- in case not found

   -- get saved configuration
   assert (loadstring (GetVariable ("configs") or "")) ()

   -- initialize mapper engine
   mapper.init { findpath = findpath,
      config = configs,            -- colours, timing etc.
      get_room = get_room,        -- get_room (uid) called to get room info
      show_help = OnHelp,         -- to show help
      room_click = room_click,    -- called on RH click on room square
      timing = show_timing,       -- want to see timing
      show_completed = show_completed,  -- want to see "Speedwalk completed." message
      show_other_areas = show_other_areas,  -- want to see areas other than the current one?
      show_up_down = show_up_down,          -- want to follow up/down exits?
      speedwalk_prefix = speedwalk_prefix,  -- how to speedwalk
   }

   -- open databases on disk
   db_name = GetVariable("db_name") or sanitize_filename(WorldName())
   SetVariable("db_name", db_name)
   worldPath = GetInfo(66)..db_name
   db = assert (sqlite3.open(worldPath..".db"))
   db:busy_timeout(100)

   if not checkDatabaseIntegrity(true) then
      return
   end

   dbCheckExecute("PRAGMA journal_mode=WAL;")

   for row in dbnrowsWRAPPER("PRAGMA user_version") do
      db_user_version = row.user_version
   end

   if GetVariable("backup_timer_enabled") ~= "1" then
      toggleBackups(false, true)
   end
   last_auto_backup = tonumber(GetVariable("last_auto_backup")) or 0

   if db_user_version < 11 then
      performing_maintenance = true
      update_db_schema(db_user_version)
   else
      after_update_check()
   end
end

function update_db_schema(db_user_version)
   print("\nThe GMCP mapper database schema has been changed.\nYour map database will now be updated.\nThis process may take a while.\n")

   -- Only go through the structure creation if we haven't done it already
   -- to save time at startup.
   if db_user_version < 2 then
      print("Updating mapper database schema to v2...")
      Repaint()
      create_tables()
      dbCheckExecute("PRAGMA user_version = 2;")
   end

   -- Database version 3 used to preload terrains, but now we dynamically
   -- request the terrains list from the server with sendgmcp request sectors
   -- se we don't need it anymore. Now we just skip from 2 to 4.

   if db_user_version < 4 then
      print("Updating mapper database schema to v4 (v3 skipped)...")
      Repaint()
      -- add bookmarks and terrain tables to the main db since we're
      -- ditching the second db file
      dbCheckExecute([[
      CREATE TABLE IF NOT EXISTS bookmarks (
         id          INTEGER PRIMARY KEY AUTOINCREMENT,
         uid         TEXT    NOT NULL,   -- vnum of room
         notes       TEXT,               -- user notes
         date_added  DATE,               -- date added to database
         UNIQUE (uid)
      );
      CREATE TABLE IF NOT EXISTS terrain (
         id          INTEGER PRIMARY KEY AUTOINCREMENT,
         name        TEXT    NOT NULL,   -- terrain name
         color       INTEGER,            -- RGB code
         date_added  DATE,               -- date added to database
         UNIQUE (name)
      );
      ]])
      dbCheckExecute("PRAGMA user_version = 4;")
   end

   if db_user_version < 5 then
      print("Updating mapper database schema to v5...")
      Repaint()
      check_rooms_flags()
      dbCheckExecute("PRAGMA user_version = 5;")
   end

   if db_user_version < 6 then
      print("Updating mapper database schema to v6...")
      dbCheckExecute("PRAGMA user_version = 6;")
   end

   if db_user_version < 7 then
      print("Updating mapper database schema to v7...")
      Repaint()
      fix_exits_table_v7()
      dbCheckExecute([[
      CREATE TABLE IF NOT EXISTS storage (
         name        TEXT NOT NULL,
         data        TEXT NOT NULL,
         PRIMARY KEY (name)
      );
      ]])
      loadstring(GetVariable("bounce_recall") or "")()
      loadstring(GetVariable("bounce_portal") or "")()
      if bounce_recall then
         dbCheckExecute(string.format("INSERT OR REPLACE INTO storage (name, data) VALUES (%s,%s);", fixsql("bounce_recall"), fixsql(serialize.save("bounce_recall"))))
      end
      if bounce_portal then
         dbCheckExecute(string.format("INSERT OR REPLACE INTO storage (name, data) VALUES (%s,%s);", fixsql("bounce_portal"), fixsql(serialize.save("bounce_portal"))))
      end
      dbCheckExecute("PRAGMA user_version = 7;")
   end

   -- I guess we skipped over v8. No big deal.

   if db_user_version < 9 then
      print("Updating mapper database schema to v9 (v8 skipped)...")
      Repaint()
      local flags_column_exists = false
      for a in dbnrowsWRAPPER("PRAGMA table_info('areas')") do
         if a["name"] == "flags" then
            flags_column_exists = true
         end
      end
      if not flags_column_exists then
         dbCheckExecute("ALTER TABLE areas ADD COLUMN flags TEXT NOT NULL DEFAULT '';")
      end
      dbCheckExecute("PRAGMA user_version = 9;")
   end

   if db_user_version < 10 then
      print("Updating mapper database schema to v10...")
      Repaint()
      remove_date_added_columns()
      local ignore_exits_mismatch_column_exists = false
      for row in dbnrowsWRAPPER("PRAGMA table_info('rooms')") do
         if row["name"] == "ignore_exits_mismatch" then
            ignore_exits_mismatch_column_exists = true
         end
      end
      if not ignore_exits_mismatch_column_exists then
         dbCheckExecute("ALTER TABLE rooms ADD COLUMN ignore_exits_mismatch INTEGER NOT NULL DEFAULT 0;")
      end
      dbCheckExecute("PRAGMA user_version = 10;")
   end

   if db_user_version < 11 then
      print("Updating mapper database schema to v11...")
      Repaint()
      dbCheckExecute("CREATE INDEX IF NOT EXISTS exits_touid_index ON exits (touid);")
      dbCheckExecute("CREATE INDEX IF NOT EXISTS rooms_area_index ON rooms (area);")
   end

   print("")
   print("Converted database to new format. Verifying integrity.")
   Repaint()
   if not checkDatabaseIntegrity(true) then
      return
   end
   dbCheckExecute("VACUUM;")
   print("")
   Repaint()
   dbCheckExecute("PRAGMA user_version = 11;")

   utils.msgbox ("Your map database schema update has completed.", "", "ok", "i")

   after_update_check()

   performing_maintenance = false
   satisfy_broadcast_queue()
end

function after_update_check()
   b_b, err, erm = io.open(worldPath.."_bookmarks.db", "r")
   if b_b ~= nil then
      io.close(b_b)
      ColourNote("white", "blue", "Found obsolete bookmarks file "..db_name.."_bookmarks.db.")
      ColourNote("white", "blue", "Merging into the main database file.")
      ColourNote("white", "blue", db_name.."_bookmarks.db --> "..db_name..".db")
      db_bm = assert (sqlite3.open(worldPath.."_bookmarks.db"))

      local bm_found = false
      for row in db_bm:nrows("SELECT name FROM sqlite_master WHERE type='table' AND name='bookmarks';") do
         bm_found = true
      end
      local tr_found = false
      for row in db_bm:nrows("SELECT name FROM sqlite_master WHERE type='table' AND name='terrain';") do
         tr_found = true
      end
      if bm_found then
         for row in db_bm:nrows("SELECT * FROM bookmarks;") do
            dbCheckExecute(string.format("INSERT OR REPLACE INTO bookmarks (uid, notes) VALUES (%s, %s);",
            fixsql(row.uid),fixsql(row.notes)))
         end
      end
      if tr_found then
         for row in db_bm:nrows("SELECT * FROM terrain;") do
            dbCheckExecute(string.format("INSERT OR REPLACE INTO terrain (name, color) VALUES (%s, %i);",
            fixsql(row.name),row.color))
         end
      end

      db_bm:close()
      ok, err = os.remove(worldPath.."_bookmarks.db")
      if not ok then
         ColourNote("white","red", "Error trying to delete obsolete file: "..worldPath.."_bookmarks.db. Please delete it manually.")
      end
   end

   -- grab all area names
   for row in dbnrowsWRAPPER("SELECT * FROM areas") do
      area = {
         name = row.name,
         texture = row.texture,
         color = row.color,
         virtual = (row.flags:find("virtual") ~= nil)
      }
      areas [row.uid] = area
   end   -- finding areas

   -- grab all user terrain info
   for row in dbnrowsWRAPPER("SELECT * FROM terrain") do
      user_terrain_colour [row.name] = row.color
   end -- finding terrains

   -- grab all environment names
   for row in dbnrowsWRAPPER("SELECT * FROM environments") do
      environments [tonumber (row.uid)] = row.name
      terrain_colours [row.name] = tonumber (row.color)
   end -- finding environments

   --~    bounce_recall = {dir="home",uid=21335}
   --~    bounce_portal = {dir="enter",uid=26151}
   for row in dbnrowsWRAPPER("SELECT * FROM storage") do
      if row.name == "bounce_portal" or row.name == "bounce_recall" then
         loadstring(row.data or "")()
      end
   end

   check_bounce_consistency()

   -- if disabled last time, stay disabled
   if GetVariable ("enabled") == "false" then
      ColourNote ("yellow", "", "Warning: Plugin " .. GetPluginName ().. " is currently disabled.")
      check (EnablePlugin(GetPluginID (), false))
      return
   end -- they didn't enable us last time

   OnPluginEnable()
   if not IsConnected() then
      OnPluginDisconnect()
   end
end

-- check for consistency mistakes with bounce portal/recall flags
function check_bounce_consistency()
   local bounce_portal_orphaned = (bounce_portal ~= nil)
   local bounce_recall_orphaned = (bounce_recall ~= nil)
   local inconsistent_recall_flag = nil
   local inconsistent_portal_flag = nil
   if (bounce_portal_orphaned or bounce_recall_orphaned) then
      local row_id = 1
      for row in dbnrowsWRAPPER(PORTALS_QUERY()) do
         if (bounce_portal_orphaned and (row.dir==bounce_portal.dir)) then
            if (row.fromuid == "*") then
               bounce_portal_orphaned = false
            else
               inconsistent_portal_flag = row_id
            end
         end
         if (bounce_recall_orphaned and (row.dir==bounce_recall.dir)) then
            if (row.fromuid == "**") then
               bounce_recall_orphaned = false
            else
               inconsistent_recall_flag = row_id
            end
         end
         row_id = row_id+1
      end
      if bounce_portal_orphaned then
         if inconsistent_portal_flag then
            ColourNote("white", "red", "BOUNCE PORTAL WARNING: Your designated bounce portal #"..inconsistent_portal_flag.." '"..bounce_portal.dir.."' was flagged as a recall, which is inconsistent with correct functioning.")
         else
            ColourNote("white", "red", "BOUNCE PORTAL WARNING: Your designated bounce portal '"..bounce_portal.dir.."' did not exist in your mapper portals list.")
         end
         ColourNote("white", "red", "BOUNCE PORTAL WARNING: Your mapper bounceportal setting will be cleared. If this is undesired, please correct the aforementioned error and set it again.")
         map_bounceportal(nil, nil, {"clear"})
      end
      if bounce_recall_orphaned then
         if inconsistent_recall_flag then
            ColourNote("white", "red", "BOUNCE RECALL WARNING: Your designated bounce recall #"..inconsistent_recall_flag.." '"..bounce_recall.dir.."' was flagged as a non-recall portal, which is inconsistent with correct functioning.")
         else
            ColourNote("white", "red", "BOUNCE RECALL WARNING: Your designated bounce recall '"..bounce_recall.dir.."' did not exist in your mapper portals list.")
         end
         ColourNote("white", "red", "BOUNCE RECALL WARNING: Your mapper bouncerecall setting will be cleared. If this is undesired, please correct the aforementioned error and set it again.")
         map_bouncerecall(nil, nil, {"clear"})
      end
   end
end

function OnPluginEnable()
   if not window_hidden then
      mapper.show()
   else
      Note("GMCP Mapper Status: Your mapper is active but hidden.")
   end
   if IsConnected() then
      OnPluginConnect()
      local status = gmcp("char.status") or {}
      mylevel = tonumber(status.level) or mylevel
      Send_GMCP_Packet("request room")
   end
   SaveState()
end -- OnPluginEnable

-- hide window on removal
function OnPluginDisable ()
   SaveState()
   mapper.hide()
   if IsConnected() then
      OnPluginDisconnect()
   end
end -- OnPluginDisable

function OnPluginClose ()
   -- if enabled
   if GetPluginInfo(GetPluginID(), 17) then
      OnPluginDisable()
   end -- if enabled
   WindowDelete(mapper.win)
end -- OnPluginClose

function OnPluginSaveState ()
   SetVariable ("window_hidden", window_hidden and "1" or "0")
   SetVariable ("enabled", tostring (GetPluginInfo (GetPluginID (), 17)))
   if mapper.config.SCAN.depth < temp_scan_depth then
      -- then we probably caught ourselves in a botched run or something
      mapper.config.SCAN.depth = temp_scan_depth
   end
   mapper.save_state()
   SetVariable("config","") -- wipe out the old config table
   SetVariable("configs", "configs = " .. serialize.save_simple (mapper.config))
   SetVariable("quick_mode", quick_mode and "1" or "0")
   SetVariable("shownotes", shownotes and "1" or "0")
   SetVariable("compact_mode", compact_mode and "1" or "0")

   -- close and reopen database
   if db:isopen() then
      db:close()
      db = assert (sqlite3.open(GetInfo (66) .. db_name .. ".db"))
   end
end -- OnPluginSaveState

-- the game's sectors, and an ANSI colour (colour_lookup) for each
terrain_colours = {
   inside = 8, city = 7, field = 10, forest = 2, hills = 3, mountain = 1,
   swim = 14, noswim = 4, unused = 0, air = 15, desert = 11,
}

-- ANSI colours lookup (for terrain_colours)
colour_lookup = {
   [0] =   ColourNameToRGB  "black",
   [1] =   ColourNameToRGB  "maroon",
   [2] =   ColourNameToRGB  "green",
   [3] =   ColourNameToRGB  "olive",
   [4] =   ColourNameToRGB  "navy",
   [5] =   ColourNameToRGB  "purple",
   [6] =   ColourNameToRGB  "teal",
   [7] =   ColourNameToRGB  "silver",
   [8] =   ColourNameToRGB  "gray",
   [9] =   ColourNameToRGB  "red",
   [10] =  ColourNameToRGB  "lime",
   [11] =  ColourNameToRGB  "yellow",
   [12] =  ColourNameToRGB  "blue",
   [13] =  ColourNameToRGB  "magenta",
   [14] =  ColourNameToRGB  "cyan",
   [15] =  ColourNameToRGB  "white",
} -- end of colour_lookup

running = false
temp_scan_depth = 4

function activate_running(run)
   running = run
   if running then
      temp_scan_depth = mapper.config.SCAN.depth
      mapper.config.SCAN.depth = 4  -- or whatever
   else
      mapper.config.SCAN.depth = temp_scan_depth
      temp_scan_depth = 4  -- or whatever
   end
   mapper.draw(current_room)
end

function compareTables(primary, secondary)
   for i,v in pairs(primary) do
      if secondary[i] ~= v then
         return false
      end
   end
   return true
end

function manual_norecall(room_id, norecall, warn_if_already_set)
   local room = load_room_from_database(room_id)
   if room ~= nil then
      if room.norecall ~= norecall then
         room.norecall = norecall
         save_room_to_database(room_id, room)
         Note("GMCP Mapper: No-recall flag "..(norecall == 1 and "set on" or "removed from").." room "..room_id..".")
      elseif warn_if_already_set then
         Note("GMCP Mapper: Room "..room_id.." already has that recall status.")
      end
   else
      Note("GMCP MAPPER NORECALL ERROR: Room "..room_id.." is not in the database.")
   end
end

function manual_noportal(room_id, noportal, warn_if_already_set)
   local room = load_room_from_database(room_id)
   if room ~= nil then
      if room.noportal ~= noportal then
         room.noportal = noportal
         save_room_to_database(room_id, room)
         Note("GMCP Mapper: No-portal flag "..(noportal == 1 and "set on" or "removed from").." room "..room_id..".")
      elseif warn_if_already_set then
         Note("GMCP Mapper: Room "..room_id.." already has that portal status.")
      end
   else
      Note("GMCP MAPPER NOPORTAL ERROR: Room "..room_id.." is not in the database.")
   end
end
function ignore_mismatch_toggle(room, room_id, ignore_exits_mismatch)
   ignore_mismatch(room_id, not ignore_exits_mismatch)
end

function ignore_mismatch(room_id, ignore_exits_mismatch)
   room_id = Trim(room_id)
   if room_id == "" then
      room_id = current_room
   end
   if room_id then
      local room = load_room_from_database(room_id)
      if room ~= nil then
         room.ignore_exits_mismatch = ignore_exits_mismatch
         save_room_to_database(room_id, room)
         Note("Ignore exits mismatch flag "..(ignore_exits_mismatch and "set on" or "removed from").." room "..room_id..".")
      else
         Note("IGNORE EXITS MISMATCH ERROR: Room "..room_id.." is not in the database.")
      end
   else
      Note("IGNORE EXITS MISMATCH ERROR: I do not know your room! Try typing 'LOOK' first, or pick a room.")
   end
end

function draw_other(name, line, wildcards)
   current_room = wildcards[1]
   mapper.draw(current_room)
end

lastarea = ""
function got_gmcp_room()
   local room_number = gmcpval("num")
   if not(room_number) then
      return
   end

   local details = gmcpval("details") or ""
   gmcproom = {
      name = strip_colours(gmcpval("name")),
      area = gmcpval("zone"),
      area_name = strip_colours(gmcpval("area") or ""),
      building = 0,
      terrain = gmcpval("terrain"),
      info = details,
      notes = "",
      x = 0,
      y = 0,
      z = 0,
      exits = {},
      exit_locks = {},
      -- the game says when a room is no_recall
      norecall = ((","..details..","):find(",norecall,", 1, true) and 1) or 0,
   }

   -- exits come as numbers; the database keeps them as text
   local exits = {}
   for dir, touid in pairs(gmcpdata.exits or {}) do
      exits[dir] = tostring(touid)
   end
   gmcpdata.exits = exits

   if gmcproom.area ~= lastarea then
      lastarea = gmcproom.area
   end

   current_room = room_number

   dbCheckExecute("BEGIN TRANSACTION;")
   ensure_area(gmcproom.area, gmcproom.area_name)
   dbCheckExecute("COMMIT;")

   local room = rooms [room_number]
   -- not cached - see if in database
   if not room then
      room = load_room_from_database (room_number)
   end -- not in cache

   local check_compact = ""
   if not compact_mode then check_compact = "\r\n" end

   if shownotes and room and room.notes and room.notes ~= "" then
      ColourNote("orange", "", "*** MAPPER NOTE *** -> "..room.notes)
      if not compact_mode then Note("") end
   end

   -- re-save if we got information that is different than before (the game
   -- sends every exit, so a n/s/e/w/u/d exit it no longer sends has gone;
   -- custom exits are ours, and stay)
   local same_exits = ((room and compareTables(gmcpdata.exits, room.exits)) or false)
   if same_exits then
      for dir in pairs(room.exits) do
         if directions[dir] and gmcpdata.exits[dir] == nil then
            same_exits = false
         end
      end
   end
   if room and room.ignore_exits_mismatch and not same_exits then
      same_exits = true
      Note("(This room has exits that don't match the MUD, but you've chosen to ignore it.)")
   end
   local same_area = ((room and (nilToStr(room.area) == nilToStr(gmcproom.area))) or false)
   if not room or nilToStr(room.name) ~= nilToStr(gmcproom.name) or
                  nilToStr(room.terrain) ~= nilToStr(gmcproom.terrain) or
                  nilToStr(room.info) ~= nilToStr(gmcproom.info) or
                  (room.norecall or 0) ~= gmcproom.norecall or
                  same_area == false or
                  same_exits == false then
      if same_area then
         gmcproom.exits = (room.exits or {})
         gmcproom.exit_locks = (room.exit_locks or {})
         gmcproom.notes = nilToStr(room.notes)
         gmcproom.noportal = (room.noportal or 0)
      elseif room and nilToStr(room.area) ~= "" and areas[nilToStr(room.area)] then
         -- replacement area
         mapper.mapprint("This room has moved areas. You should 'mapper purgezone "..nilToStr(room.area).."' if this new area replaces it.")
         map_purgeroom (nilToStr(room_number), gmcproom.area)
      else
         -- brand new area
         gmcproom.exits = {}
         gmcproom.exit_locks = {}
         gmcproom.notes = ""
         gmcproom.noportal = 0
      end
      dbCheckExecute("BEGIN TRANSACTION;")
      local success = save_room_to_database(room_number, gmcproom)
      if success then
         rooms[room_number] = gmcproom
         if not same_exits or not same_area then
            save_room_exits(room_number)
         end
      end
      dbCheckExecute("COMMIT;")

      if not success then
         return
      end
   end -- if room not there

   mapper.draw(room_number)

   if expected_exit == "0" and from_room then
      fix_up_exit ()
   end -- exit was wrong

   return
end

function save_room_exits(uid)
   if rooms[uid] == nil then
      return
   end
   if gmcpdata.exits ~= nil then
      for dir,touid in pairs(gmcpdata.exits) do
         if dir then
            dbCheckExecute (string.format ([[
               INSERT OR REPLACE INTO exits (dir, fromuid, touid)
               VALUES (%s, %s, %s);
               ]], fixsql  (dir),  -- direction (eg. "n")
               fixsql  (uid),  -- from current room
               fixsql  (touid) -- destination room
            ))

            if show_database_mods then
               mapper.mapprint ("Added exit: ", dir, "from room: ",uid, "to room: ", touid, " to database.")
            end -- if

            if rooms[uid].exits[dir] ~= touid then
               rooms[uid].exit_locks[dir] = "0"
            end
            rooms[uid].exits[dir] = touid
         else
            mapper.maperror ("Cannot make sense of:", exit)
         end -- if can decode
      end -- for each exit
      -- n/s/e/w/u/d exits the game no longer has (custom exits stay)
      for dir in pairs(rooms[uid].exits) do
         if directions[dir] and gmcpdata.exits[dir] == nil then
            dbCheckExecute (string.format ("DELETE FROM exits WHERE fromuid = %s AND dir = %s;",
               fixsql (uid), fixsql (dir)))
            rooms[uid].exits[dir] = nil
            rooms[uid].exit_locks[dir] = nil
         end
      end
   end -- have exits.
end -- save_room_exits

broadcast_queue = {}
function queue_broadcast(msg, id, name, text)
   table.insert(broadcast_queue, {["msg"]=msg, ["id"]=id, ["name"]=name, ["text"]=text})
end

function satisfy_broadcast_queue()
   for i,v in ipairs(broadcast_queue) do
      OnPluginBroadcast(v.msg, v.id, v.name, v.text)
   end
   broadcast_queue = {}
end

function OnPluginBroadcast (msg, id, name, text)
   if id == GMCP_HANDLER then
      if performing_maintenance == true then
         queue_broadcast(msg, id, name, text)
         return
      end

      if text == "room.info" then
         gmcpdata = gmcp("room.info") or {}
         got_gmcp_room()
      elseif text == "char.status" then
         local status = gmcp("char.status") or {}
         mylevel = tonumber(status.level) or mylevel
      elseif text == "char.vitals" then
         local vitals = gmcp("char.vitals") or {}
         fighting_state = (vitals.enemy or "") ~= ""
      end
   end
end

function quick_mode_toggle(name, line, wildcards)
   if (wildcards[2] ~= "") then
      quick_mode = (wildcards[2] == "on")
   end
   if not compact_mode then Note("") end
   if quick_mode then
      Note("QUICKLIST is ON: Lists of found targets will display immediately without sorting by distance.")
   else
      Note("QUICKLIST is OFF: Lists of found targets will pathfind each result and sort them by distance.")
   end
   if not compact_mode then Note("") end
end

function shownote_toggle(name, line, wildcards)
   if (wildcards[2] ~= "") then
      shownotes = (wildcards[2] == "on")
   end
   if not compact_mode then Note("") end
   if shownotes then
      Note("SHOWNOTES is ON: Notes added to rooms will display automatically upon entrance.")
   else
      Note("SHOWNOTES is OFF: Notes added to rooms will NOT display automatically.")
   end
   if not compact_mode then Note("") end
end

function compact_mode_toggle(name, line, wildcards)
   if (wildcards[2] ~= "") then
      compact_mode = (wildcards[2] == "on")
   end
   if not compact_mode then Note("") end
   if compact_mode then
      Note("COMPACT is ON: Mapper output won't display blank lines. Please report any that might show.")
   else
      Note("COMPACT is OFF: Mapper output will display blank lines (default behaviour).")
   end
   if not compact_mode then Note("") end
end

-- Queries db for a list of rooms and passes them to mapper.find(). Can be used with CallPlugin().
-- query: SQL query to run. Should return a list of rooms with uids. Example:
--        "SELECT uid FROM rooms WHERE name = 'The Grand City of Aylor'"
-- The remaining args are the same as the args to find() in aardmapper.lua. Their default values
-- are given below. "fcb" is missing here since function args can't be used with CallPlugin().
-- name: Defaults to nil.
-- max_paths: Defaults to 0 (unlimited).
-- show_uid: Defaults to true.
-- walk: Defaults to false.
-- quick_list: Defaults to false.
-- no_portals: Defaults to false.
function map_find_query(query, name, max_paths, show_uid, walk, quick_list, no_portals)
   -- Default arguments.
   max_paths = max_paths or 0
   if show_uid == nil then show_uid = true end
   walk = walk or false
   quick_list = quick_list or false
   no_portals = no_portals or false

   local rooms = {}
   local count = 0

   for row in dbnrowsWRAPPER(query) do
      table.insert(rooms, {uid=row.uid, reason=true})
      count = count + 1
   end

   mapper.find(
      name,
      rooms,
      max_paths,
      show_uid,
      count,      -- expected_count
      walk,
      nil,        -- fcb
      quick_list
   )
end -- map_find_query

function map_area (name, line, wildcards)

   uid = mapper.current_room

   -- check we got room at all
   if not uid then
      print("I do not know your room! Try typing 'LOOK' first.")
      return
   end -- if

   -- look it up
   local ourroom = rooms [uid]

   -- not cached - see if in database
   if not ourroom then
      ourroom = load_room_from_database (uid)
      rooms [uid] = ourroom -- cache for later
   end -- not in cache

   if not ourroom then
      print("AREA ERROR: The area has not been initialized yet. Please try again in a second.")
      return
   end -- if

   local area = rooms[uid].area

   local key = Trim(wildcards[1])
   local name = "%"..key.."%"
   if string.sub(key,1,1) == "\"" and string.sub(key,-1) == "\"" then
      name = Trim(string.sub(key,2,-2))
   end

   map_find_query(
      string.format("SELECT uid, name, area FROM rooms WHERE trim(name) LIKE %s and area = %s",fixsql(name), fixsql(area)),
      name,
      50,          -- max_paths
      show_vnums,  -- show_uid
      false,       -- walk
      quick_mode   -- quick_list
   )
end -- map_area


function map_find (name, line, wildcards)
   -- find matching rooms using FTS3
   local name = "%"..wildcards[1].."%"

   if string.sub(wildcards[1],1,1) == "\"" and string.sub(wildcards[1],-1) == "\"" then
      name = string.sub(wildcards[1],2,-2)
   end

   map_find_query(
      string.format("SELECT uid, name FROM rooms_lookup WHERE rooms_lookup.name LIKE %s", fixsql (name)),
      name,
      50,          -- max_paths
      show_vnums,  -- show_uid
      false,       -- walk
      quick_mode   -- quick_list
   )
end -- map_find


function map_find_special(which_ones, which_area)
   local wanted_items = {}

   for _, v in ipairs (which_ones) do
      wanted_items [v:lower ()] = true
   end

   local rooms = {}
   local count = 0

   local current_area = gmcpval("zone")

   area = Trim(which_area or "")
   if area == "" then
      mapper.mapprint("Searching all areas")
      query = "SELECT uid, name, info FROM rooms WHERE rooms.info IS NOT NULL"
   else
      if area == "here" then
         area = current_area
         mapper.mapprint("Searching current area")
      else
         area = "%"..area.."%"
         mapper.mapprint("Searching areas that partially match %"..area.."%")
      end
      query = string.format("SELECT uid, name, info FROM rooms WHERE rooms.info IS NOT NULL AND lower(rooms.area) LIKE %s", fixsql(area))
   end -- if area

   -- build table of special places (with info in them)
   for row in dbnrowsWRAPPER(query) do
      if row.info ~= "" then
         local wanted = false
         local t = {}
         for item in string.gmatch (row.info, "[^,]+") do
            if wanted_items [item:lower ()] then
               wanted = true
               table.insert (t, capitalize (item))
            end -- if
         end -- for
         if wanted then
            table.insert(rooms, {uid=row.uid, reason=table.concat(t, ", ")})
            count = count + 1
         end -- if
      end -- if
   end   -- finding room

   -- find such places
   mapper.find (table.concat(which_ones,","),
      rooms,
      0,
      show_vnums,  -- show vnum?
      count,      -- how many to expect
      false,       -- don't auto-walk
      nil,
      true         -- quick list
   )
end -- map_find_special

function map_shops (name, line, wildcards)
   map_find_special ({ "shop", "bank", }, wildcards[1])
end -- map_shops

function map_trainers (name, line, wildcards)
   map_find_special ({ "trainer", "guild", }, wildcards[1])
end -- map_trainers

function map_quests (name, line, wildcards)
   map_find_special ({ "questor", }, wildcards[1])
end -- map_quests

function map_healers (name, line, wildcards)
   map_find_special ({ "healer", }, wildcards[1])
end -- map_healers


function map_goto (name, line, wildcards)

   if not mapper.check_connected() then
      return
   end
   local wanted = string.match(wildcards[1], "^(nomap_.+)$") or tonumber(wildcards [1])
   if not wanted or (type(wanted) == "number" and  wanted < 0) then
      mapper.mapprint ("The mapper "..(((string.match(line, "^mapper walkto") ~= nil) and "walkto") or "goto").." command expects a room id as input.")
      return
   end
   wanted = tostring(wanted)
   if current_room and (wanted == current_room) then
      mapper.mapprint ("You are already in that room.")
      return
   end -- if

   -- find desired room
   mapper.find (nil,
      {{uid=wanted, reason=true}},
      0,
      show_vnums,  -- show vnum?
      1,          -- how many to expect
      true,        -- just walk there
      nil,
      nil,
      string.match(line, "^mapper walkto") ~= nil
   )
end -- map_goto

function printpath_alias(name, line, wildcards)
   printpath(wildcards[1], wildcards[2])
end

function printpath(src, dest)
   if src == dest then
      mapper.mapprint ("Pick different start and end rooms.")
      return
   end

   rooms[src] = load_room_from_database(src)
   rooms[dest] = load_room_from_database(dest)

   if rooms[src] == nil then
      mapper.mapprint (string.format ("Room %s not known.", src))
      return
   end
   if rooms[dest] == nil then
      mapper.mapprint (string.format ("Room %s not known.", dest))
      return
   end

   local foundpath = findpath(src, dest)
   if foundpath == nil then
      mapper.mapprint (string.format ("Path from %s to %s not found.", src, dest))
      return
   end

   -- turn into speedwalk
   local speedwalk, stack_char = mapper.build_speedwalk(foundpath, speedwalk_prefix)
   -- hack to split runs of exits apart with spaces for better screen reader sound
   local split_speedwalk = speedwalk:split(stack_char)
   for i,v in ipairs(split_speedwalk) do
      local parts = Trim(v):split("run ", false, 1)
      if (parts[1] == "") and (parts[2] ~= "") then -- found a run section
         split_speedwalk[i] = Trim("run "..parts[2]:gsub("%a", "%1 "))
      end
   end

   speedwalk = table.concat(split_speedwalk, " ; ")

   -- display it
   if current_room ~= src then
      mapper.mapprint (string.format ("Path from %s to %s is:\n%s\n", src, dest, speedwalk))
   else
      mapper.mapprint (string.format ("Path to %s is:\n%s\n", dest, speedwalk))
   end
end

function map_where (name, line, wildcards)
   if not mapper.check_we_can_find () then
      return
   end -- if

   local dest = tonumber(Trim(wildcards[1]))
   if not dest then
      dest = tonumber(mapper.last_hyperlink_uid or mapper.last_speedwalk_uid)
   end
   if not dest then
      mapper.mapprint ("The mapper where command expects a room id number as input.")
      return
   end

   dest = tostring(dest)
   if dest == current_room then
      mapper.mapprint ("You are already in that room.")
      return
   end -- if

   printpath(current_room, dest)
end -- map_where

function map_resume (name, line, wildcards)

   local wanted = tonumber(mapper.last_hyperlink_uid or mapper.last_speedwalk_uid)

   if not wanted then
      mapper.print "No outstanding speedwalks or hyperlinks."
      return
   end -- if nothing to do

   wanted = tostring(wanted)
   -- find desired room
   mapper.find (nil,
      {{uid=wanted,reason=true}},
      0,  -- function
      show_vnums,  -- show vnum?
      1,      -- how many to expect
      true    -- just walk there
   )
end -- map_resume

function map_notes (name, line, wildcards)
   local rooms = {}
   local count = 0
   local area = Trim(wildcards[2] or "")

   local q = string.format ("SELECT bookmarks.uid as uid, bookmarks.notes as notes FROM bookmarks")

   if area == "room" or area == "thisroom" then
      if not current_room then
         mapper.mapprint("I don't know where you are right now - try: LOOK")
         return
      else
         mapper.mapprint("Searching the current room")
         q = q..",rooms where bookmarks.uid=rooms.uid and rooms.uid="..fixsql(current_room)
      end
   else
      if area ~= "" then
         if area == "here" then
            area = gmcpval("zone")
            mapper.mapprint("Searching the current area")
         else
            mapper.mapprint("Searching areas that partially match '"..area.."'")
            area = "%"..area.."%"
         end
         q = q..",rooms where bookmarks.uid=rooms.uid and rooms.area like "..fixsql(area)
      else
         mapper.mapprint("Searching all areas")
      end
   end

   -- build table of special places (with info in them)
   for row in dbnrowsWRAPPER(q) do
      table.insert(rooms,{uid=row.uid, reason=row.notes})
      count = count + 1
   end   -- finding room

   -- find such places
   mapper.find ("[NOTE]",
      rooms,
      0,  -- function
      show_vnums,  -- show vnum?
      count,       -- how many to expect
      false,        -- don't auto-walk
      nil,
      true  -- quick list
   )
end -- map_notes

local bit = require("bit")

-- original findpath function idea contributed by Spartacus
function findpath(src, dst, noportals, norecalls)
   if not rooms[src] then
      rooms[src] = load_room_from_database(src)
   end
   if not rooms[src] then
      return
   end

   local walk_one = nil
   for dir,touid in pairs(rooms[src].exits) do
      if tostring(touid) == tostring(dst) and tonumber(rooms[src].exit_locks[dir]) <= mylevel and ((walk_one == nil) or (#dir > #walk_one)) then
         walk_one = dir -- if one room away, walk there (don't portal), but prefer a cexit
      end
   end
   if walk_one ~= nil then
      local path = {{dir=walk_one, uid=touid}}
      BroadcastPlugin(502, "found_paths = "..string.gsub(serialize.save_simple(path),"%s+"," "))
      return path, 1
   end
   local depth = 0
   local max_depth = mapper.config.SCAN.depth
   local room_sets = {}
   local rooms_list = {}
   local found = false
   local ftd = {}
   local f = ""
   local next_room = 0

   if type(src) ~= "number" then
      src = string.match(src, "^(nomap_.+)$") or tonumber(src)
   end
   if type(dst) ~= "number" then
      dst = string.match(dst, "^(nomap_.+)$") or tonumber(dst)
   end

   if src == dst or src == nil or dst == nil then
      return {}
   end

   src = tostring(src)
   dst = tostring(dst)

   table.insert(rooms_list, fixsql(dst))

   local visited = ""
   local main_status = GetInfo(53):gsub(" %(mapper searching depth %d+%)", "")
   while not found and depth < max_depth do
      SetStatus(main_status.." (mapper searching depth "..depth..")")
      Repaint()
      depth = depth + 1
      if depth > 1 then
         ftd = room_sets[depth-1] or {}
         rooms_list = {}
         for k,v in pairs(ftd) do
            table.insert(rooms_list, fixsql(v.fromuid))
         end -- for from, to, dir
      end -- if depth

      -- prune the search space
      if visited ~= "" then
         visited = visited..","..table.concat(rooms_list, ",")
      else
         if noportals then
            visited = visited..fixsql("*")..","
         end
         if norecalls then
            visited = visited..fixsql("**")..","
         end
         visited = visited..table.concat(rooms_list, ",")
      end

      -- get all exits to any room in the previous set
      local q = string.format ("select fromuid, touid, dir from exits where touid in (%s) and fromuid not in (%s) and ((fromuid not in ('*','**') and level <= %s) or (fromuid in ('*','**') and level <= %s)) order by length(dir) asc",table.concat(rooms_list,","), visited,mylevel,mylevel+(mytier*10))
      local dcount = 0
      room_sets[depth] = {}
      for row in dbnrowsWRAPPER(q) do
         dcount = dcount + 1
         -- ordering by length(dir) ensures that custom exits (always longer than 1 char) get
         -- used preferentially to normal ones (1 char)
         room_sets[depth][row.fromuid] = {fromuid=row.fromuid, touid=row.touid, dir=row.dir}
         if row.fromuid == "*" or (row.fromuid == "**" and f ~= "*" and f ~= src) or row.fromuid == src then
            f = row.fromuid
            found = true
            found_depth = depth
         end -- if src
      end -- for select

      if dcount == 0 then
         found = false
         break -- there is no path from here to there
      end -- if dcount
   end -- while

   SetStatus(main_status)

   if found == false then
      return
   end

   -- We've gotten back to the starting room from our destination. Now reconstruct the path.
   local path = {}
   -- set ftd to the first from,to,dir set where from was either our start room or * or **
   ftd = room_sets[found_depth][f]

   if (f == "*" and rooms[src].noportal == 1) or (f == "**" and rooms[src].norecall == 1) then
      if rooms[src].norecall ~= 1 and bounce_recall ~= nil then
         table.insert(path, bounce_recall)
         if dst == bounce_recall.uid then
            return path, found_depth
         end
      elseif rooms[src].noportal ~= 1 and bounce_portal ~= nil then
         table.insert(path, bounce_portal)
         if dst == bounce_portal.uid then
            return path, found_depth
         end
      else
         local jump_room, path_type = findNearestJumpRoom(src, dst, f)
         if not jump_room then
            return
         end
         local path, first_depth = findpath(src,jump_room, true, true) -- this could be optimized away by building the path in findNearestJumpRoom, but the gain would be negligible
         if bit.band(path_type, 1) ~= 0 then
            -- path_type 1 means just walk to the destination
            return path, first_depth
         else
            local second_path, second_depth = findpath(jump_room, dst)
            for i,v in ipairs(second_path) do
               table.insert(path, v) -- bug on this line if path is nil?
            end
            return path, first_depth+second_depth
         end
      end
   end

   table.insert(path, {dir=ftd.dir, uid=ftd.touid})

   next_room = ftd.touid
   while depth > 1 do
      depth = depth - 1
      ftd = room_sets[depth][next_room]
      next_room = ftd.touid
-- this caching is probably not noticeably useful, so disable it for now
--      if not rooms[ftd.touid] then -- if not in memory yet, get it
--         rooms[ftd.touid] = load_room_from_database (ftd.touid)
--      end
      table.insert(path, {dir=ftd.dir, uid=ftd.touid})
   end -- while
   BroadcastPlugin(502, "found_paths = "..string.gsub(serialize.save_simple(path),"%s+"," "))
   return path, found_depth
end -- function findpath

-- Very similar to findpath, but looks forwards instead of backwards (so only walks)
-- and stops at the nearest portalable or recallable room
function findNearestJumpRoom(src, dst, target_type)
   local depth = 0
   local max_depth = mapper.config.SCAN.depth
   local room_sets = {}
   local rooms_list = {}
   local found = false
   local ftd = {}
   local destination = ""
   local next_room = 0
   local visited = ""
   local path_type = ""

   table.insert(rooms_list, fixsql(src))
   local main_status = GetInfo(53):gsub(" %(mapper searching jump depth %d+%)", "")
   while not found and depth < max_depth do
      SetStatus(main_status.." (mapper searching jump depth "..depth..")")
      Repaint()
      depth = depth + 1

      -- prune the search space
      if visited ~= "" then
         visited = visited..","..table.concat(rooms_list, ",")
      else
         visited = table.concat(rooms_list, ",")
      end

      -- get all exits to any room in the previous set
      local q = string.format ("select fromuid, touid, dir, norecall, noportal from exits,rooms where rooms.uid = exits.touid and exits.fromuid in (%s) and exits.touid not in (%s) and exits.level <= %s order by length(exits.dir) asc",
                  table.concat(rooms_list,","), visited, mylevel)
      local dcount = 0
      for row in dbnrowsWRAPPER(q) do
         dcount = dcount + 1
         table.insert(rooms_list, fixsql(row.touid))
         -- ordering by length(dir) ensures that custom exits (always longer than 1 char) get
         -- used preferentially to normal ones (1 char)
         if ((bounce_portal ~= nil or target_type == "*") and row.noportal ~= 1) or ((bounce_recall ~= nil or target_type == "**") and row.norecall ~= 1) or row.touid == dst then
            path_type = ((row.touid == dst) and 1) or ( (((row.noportal == 1) and 2) or 0) + (((row.norecall == 1) and 4) or 0) )
            -- path_type 1 means walking to the destination is closer than bouncing
            -- path_type 2 means the bounce room allows recalling but not portalling
            -- path_type 4 means the bounce room allows portalling but not recalling
            -- path_type 0 means the bounce room allows both portalling and recalling
            destination = row.touid
            found = true
            found_depth = depth
         end -- if src
      end -- for select

      if dcount == 0 then
         found = false
         break -- there is no path to a portalable or recallable room
      end -- if dcount
   end -- while

   if found == false then
      return
   end
   return destination, path_type, found_depth
end

valid_direction = {
   n = "n",
   s = "s",
   e = "e",
   w = "w",
   u = "u",
   d = "d",
   north = "n",
   south = "s",
   east = "e",
   west = "w",
   up = "u",
   down = "d",
}  -- end of valid_direction

directions = {
   n=true,
   s=true,
   e=true,
   w=true,
   u=true,
   d=true
}

-- try to detect when we send a movement command
function OnPluginSent (sText)
   if valid_direction [sText] then
      last_direction_moved = valid_direction [sText]
      if current_room and rooms [current_room] then
         expected_exit = rooms [current_room].exits [last_direction_moved]
         if expected_exit then
            from_room = current_room
         end -- if
      end -- if
   end -- if
end -- function

function toggleBackups(on, quiet)
   if on then
      EnableTimer("backup_timer", true)
      if not quiet then Note("Automated map database backup enabled.") end
      if ((os.time()-last_auto_backup) >= 86401) then
         Note("It looks like it has been more than 24 hours since your last automatic mapper database backup!")
         Note("Forcing an automatic backup now...")
         if not compact_mode then Note("") end
         Repaint()
         automatic_backup()
      end
   else
      EnableTimer("backup_timer", false)
      if not quiet then Note("Automated map database backup disabled.") end
   end
end

function OnPluginConnect ()
   if not db:isopen() then
      maybe_Note("GMCP Mapper: Reconnected; opening map database.")
      db = assert (sqlite3.open(GetInfo (66) .. db_name .. ".db"))
   end
   if GetVariable("backup_timer_enabled") == "1" then
      if (os.time()-last_auto_backup) < 86401 then
         local seconds = 86400-(os.time()-last_auto_backup)
         local hours = math.floor(seconds/3600)
         seconds = seconds - hours*3600
         local minutes = math.floor(seconds/60)
         seconds = math.floor(seconds - minutes*60)
         -- replace the timer with a new one that has the right time
         AddTimer("backup_timer", hours, minutes, seconds, "", timer_flag.Replace + timer_flag.Enabled + timer_flag.ActiveWhenClosed + timer_flag.OneShot, "automatic_backup")
      end
      toggleBackups(true, true)
   end
   mapper.cancel_speedwalk ()
end -- OnPluginConnect

function OnPluginDisconnect ()
   fighting_state = false
   mapper.cancel_speedwalk ()
   if db:isopen() then
      db:close()
   end
   if GetVariable("backup_timer_enabled") == "1" then
      toggleBackups(false, true)
   end
end -- OnPluginConnect

function OnHelp (name, line, wildcards)
   local help_header = "                     [Murderous Intentions GMCP Mapper Help]"

   local help_index = [[
                           Mapper Help Index
=============================================================================
 mapper help               --> Show this list
 mapper help all           --> Show the entire list of all mapper commands
-----------------------------------------------------------------------------
 mapper help config        --> Commands for configuring the mapper
 mapper help exits         --> Commands for managing exits
 mapper help portals       --> Commands for managing portals
 mapper help searching     --> Commands for finding rooms
 mapper help exploring     --> Commands to aid exploring
 mapper help moving        --> Commands for moving between rooms
 mapper help utils         --> Other utilitarian commands
 mapper help search <txt>  --> Searches through help lines looking for a
                               particular word or phrase.
=============================================================================
]]
   local help_table = {
   ['config'] = {
['header'] = [[===== CONFIGURATION =============>]],
[[mapper quicklist [on/off]      --> ON will cause search results to display
                                 > much faster, but the results will not be
                                 > sorted by distance (default is on)]],
[[mapper shownotes [on/off]      --> ON will cause room notes to display
                                 > automatically upon entering (default is on)]],
[[mapper compact [on/off]        --> ON will make it so no blank lines are
                                 > displayed by the mapper (default is off)]],
[[mapper backups <off/on>        --> Turn off or on automatic database backups
                                 > The default setting is on]],
[[mapper backups quiet           --> Toggle whether messages are shown during
                                 > backups]],
[[mapper backups [un]compressed  --> Turn off or on database backup compression
                                 > (needs zip.exe in the MUSHclient folder)
                                 > The default setting is uncompressed (off)]],
[[mapper help                    --> This help
                                 > (or click the "?" button on the top right)]],
[[mapper reset                   --> Put the map window back where it started]],
[[mapper zoom out                --> Zoom out]],
[[mapper zoom in                 --> Zoom in]],
[[mapper hide                    --> Hide map]],
[[mapper show                    --> Show map]],
[[mapper updown                  --> Toggle up/down exit drawing]],
[[mapper underlines              --> Toggle underlining of clickable links]],
[[mapper database                --> Print the name of the map database file.]],
[[mapper set database <new_name> --> Change the map database file.]]
},

   ['utils'] = {
['header'] = [[===== UTILITIES =================>]],
[[mapper backup                  --> Create new archived backup of your map
                                 > database in a db_backups directory,
                                 > preserving a few prior backups]],
[[mapper addnote                 --> Add a new note to the current room]],
[[mapper addnote <note>          --> Ditto, but skips the dialog]],
[[mapper delete note             --> Delete the note in the current room
                                 > without using the addnote dialog]],
[[mapper purgezone <area>        --> Delete an area from the map database]],
[[mapper purgeroom               --> Delete the current room from the database]],
[[mapper ignore mismatch <true/false> --> Don't change the room in the database
                                      > if only the exits are "wrong"]]
},

   ['exits'] = {
['header'] = [[===== EXIT ACTIONS ==============>]],
[[mapper cexits                  --> List known custom exits]],
[[mapper cexits <here/area>      --> List known custom exits only in this or
                                 > another area]],
[[mapper cexit <command>         --> Follow and link a custom exit
                                 > (ex: 'mapper cexit ride bucket')
                                 > To insert a pause during execution
                                 > of the cexit, use wait(<seconds>) as one
                                 > or more of the cexit moves
                                 > To stack commands use ;; as separator
                                 > to get around the line break parser
                                 > (ex: 'mapper cexit open south;;south')]],
[[mapper delete exits from <room> --> Delete exits from the given room ID to
                                     the current room.]],
[[mapper delete exits to <room>   --> Delete exits to the given room ID from
                                     the current room.]],
[[mapper delete cexits           --> Remove the custom exits from this room]],
[[mapper purge cexits            --> Remove all custom exits in database]],
[[mapper cexit_wait <seconds>    --> Wait this number of seconds instead of
                                 > the standard 2 when constructing the next
                                 > cexit (between 2 and 40)]],
[[mapper lockexit                --> Bring up the exit level-locking dialog
                                 > for the current room.]],
[[]],
[[There is also 'mapper fullcexit {<command>} <source> <destination> <level>'
which lets you set all cexit aspects in one command without running it.]],
[[]]
},

   ['portals'] = {
['header'] = [[===== PORTAL ACTIONS ============>]],
[[mapper portals                 --> List known hand-held portals]],
[[mapper portals here/<area>     --> List known hand-held portals only to this
                                 > or another area (by area keyword).]],
[[mapper portal <command>        --> Link a handheld portal to the current
                                 > room as a special exit from everwhere else
                                 > (ex: 'mapper portal recall' at recall).
                                 > To stack commands use ;; as separator
                                 > to get around the line-break parser
                                 > (ex: 'mapper portal hold amulet;;enter').]],
[[]],
[[There is also 'mapper fullportal {<command>} {<room_id>} <level>' which lets
you set all portal aspects in one command without being in the target room.]],
[[]],
[[+---- NORECALL/NOPORTAL ROOM ASSISTANCE -------------------------------------+]],
[[mapper portalrecall <index>    --> Flag/unflag a portal as using a recall or
                                 > home command, to avoid using it in
                                 > identified norecall rooms.
                                 > Find the indices with 'mapper portals']],
[[mapper bounceportal <index>    --> Specifies which non-recall mapper portal
                                 > to bounce through when the path calculation
                                 > wants to recall or home from a
                                 > portal-friendly norecall room. For this to
                                 > work properly you must indicate which mapper
                                 > portals use recall or home with the
                                 > portalrecall command listed above.
                                 > Find the indices with 'mapper portals']],
[[mapper bouncerecall <index>    --> Specifies which home/recall mapper portal
                                 > to bounce through when the path calculation
                                 > wants to portal from a recall-friendly
                                 > noportal room. You may only choose a portal
                                 > that has been marked as being a recall
                                 > portal using the portalrecall command listed
                                 > above.
                                 > Find the indices with 'mapper portals']],
[[mapper bounceportal            --> Display the current bounce portal]],
[[mapper bouncerecall            --> Display the current bounce recall]],
[[mapper bounceportal clear      --> Clear the current bounce portal]],
[[mapper bouncerecall clear      --> Clear the current bounce recall]],
[[+----------------------------------------------------------------------------+]],
[[mapper noportal <id> [true/false] --> Manually set noportal flag]],
[[mapper norecall <id> [true/false] --> Manually set norecall flag (the game
                                    > marks no_recall rooms by itself)]],
[[+----------------------------------------------------------------------------+]],
[[]],
[[mapper portallevel <ind> <lvl> [quiet] --> Change the level lock on a portal.
                                           > Find indices with 'mapper portals'.
                                           > Adding 'quiet' means no output.]],
[[mapper delete portal <command> --> Remove the specified hand-held portal alias]],
[[mapper delete portal #<index>  --> Remove a hand-held portal by its index
                                 > Find the indices with 'mapper portals']],
[[mapper change portal {<old cmd>} {<new cmd>} --> Change a portal command.]],
[[mapper change portal #<index> {<new cmd>}    --> Change a portal command
                                               > by index.]],
[[mapper purge portals           --> Remove all hand-held portal aliases]]
},

   ['searching'] = {
['header'] = [[===== SEARCHING =================>]],
[[mapper area <text>             --> Full-text search limited to the current zone]],
[[mapper find <text>             --> Full-text search the whole database]],
[[mapper list <text>             --> Find rooms without the known-path limits
                                 > of "area" and "find"]],
[[]],
[[mapper notes                   --> Show nearby rooms that you marked with notes]],
[[mapper notes <here/area>       --> Ditto]],
[[mapper shops                   --> Show all shops/banks]],
[[mapper shops <here/area>       --> Ditto]],
[[mapper train                   --> Show all trainers and guildmasters]],
[[mapper train <here/area>       --> Ditto]],
[[mapper heal                    --> Show all healers]],
[[mapper heal <here/area>        --> Ditto]],
[[mapper quest                   --> Show all quest-givers]],
[[mapper quest <here/area>       --> Ditto]],
[[]],
[[mapper next                    --> Visit the next room in the most recent
                                 > list of results.]],
[[mapper next <index>            --> Ditto, but skip to the given result index.]],
[[mapper where <room id>         --> Show directions to a room number]]
},

   ['exploring'] = {
['header'] = [[===== EXPLORING =================>]],
[[mapper thisroom                --> Show details about the current room]],
[[mapper showroom <room id>      --> Draw the map as if you were standing in
                                 > a different room]],
[[mapper areas                   --> Show a list of all mapped areas]],
[[mapper areas <name>            --> Show a list of mapped areas partially
                                 > matching <name>]],
[[mapper unmapped                --> List unmapped exit counts for known areas]],
[[mapper unmapped <here/area>    --> List unmapped exits in this or another area]]
},

   ['moving'] = {
['header'] = [[===== MOVING ====================>]],
[[mapper goto <room id>          --> Run to a room by its room number]],
[[mapper walkto <room id>        --> Run to a room by its room number without
                                 > using any mapper portals]],
[[mapper resume                  --> Initiate a new run to the previous target]]
}
}

   local function show_help(helps)
      print("")
      print(helps['header'])
      print("")
      for i,v in ipairs(helps) do
         print(v)
      end
   end

   print("")
   print(help_header)
   print("+---------------------------------------------------------------------------+")
   badnews = true
   if wildcards and wildcards[2] ~= "" then
      badnews = false
      if wildcards[2] == "all" then
         show_help(help_table['config'])
         show_help(help_table['exits'])
         show_help(help_table['portals'])
         show_help(help_table['searching'])
         show_help(help_table['exploring'])
         show_help(help_table['moving'])
         show_help(help_table['utils'])
      elseif wildcards[2] == "config" then
         show_help(help_table['config'])
      elseif wildcards[2] == "exits" then
         show_help(help_table['exits'])
      elseif wildcards[2] == "portals" then
         show_help(help_table['portals'])
      elseif wildcards[2] == "searching" then
         show_help(help_table['searching'])
      elseif wildcards[2] == "exploring" then
         show_help(help_table['exploring'])
      elseif wildcards[2] == "moving" then
         show_help(help_table['moving'])
      elseif wildcards[2] == "utils" then
         show_help(help_table['utils'])
      elseif wildcards[2]:find("search ") == 1 then
         local st, en = wildcards[2]:find("search ")
         local pattern = Trim(wildcards[2]:sub(en+1))
         ColourTell("cornflower","black","Searching help for: ")
         ColourNote("red","black",pattern)
         if pattern ~= "" then
            nocase_pattern = case_insensitive_pattern(pattern)
            for k,v in pairs(help_table) do
               local done_header = false
               for j,w in ipairs(v) do
                  if (w ~= "") and (w:find("--------",nil,true) == nil) and (w:find(nocase_pattern) ~= nil) then
                     if not done_header then
                        print("")
                        print(v['header'])
                        done_header = true
                     end
                     print("")
                     local wtable = w:split(nocase_pattern, true)
                     odd = true
                     for i,v in ipairs(wtable) do
                        if odd then
                           ColourTell("cornflower","black",v)
                        else
                           ColourTell("red", "black", v)
                        end
                        odd = not odd
                     end
                     Note("")
                  end
               end
            end
         else
            badnews = true
         end
      else
         badnews = true
      end
   end
   if badnews then
      print("")
      mapper.mapprint (world.GetPluginInfo (world.GetPluginID (), 3))
      print("")
      print(help_index)
   end
   print("")
   print("+---------------------------------------------------------------------------+")
   print("")
end


function case_insensitive_pattern(pattern)

  -- find an optional '%' (group 1) followed by any character (group 2)
  local p = pattern:gsub("(%%?)(.)", function(percent, letter)

    if percent ~= "" or not letter:match("%a") then
      -- if the '%' matched, or `letter` is not a letter, return "as is"
      return percent .. letter
    else
      -- else, return a case-insensitive character class of the matched letter
      return string.format("[%s%s]", letter:lower(), letter:upper())
    end

  end)

  return p
end



function nilToStr(n)
   return (((n ~= nil) and tostring(n)) or "")
end

function getmemoryusage()
  collectgarbage('collect')
  return collectgarbage('count')
end

]]>
</script>
</muclient>
