Quantcast
--[[****************************************************************************
  * _NPCScan by Saiket                                                         *
  * _NPCScan.lua - Scans NPCs near you for specific rare NPC IDs.              *
  ****************************************************************************]]


local me = select( 2, ... );
_NPCScan = me;
local L = me.L;

me.Frame = CreateFrame( "Frame" );
me.Updater = me.Frame:CreateAnimationGroup();
me.Version = GetAddOnMetadata( ..., "Version" ):match( "^([%d.]+)" );

me.Options = {
	Version = me.Version;
};
me.OptionsCharacter = {
	Version = me.Version;
	NPCs = {};
	NPCWorldIDs = {};
	Achievements = {};
};

me.OptionsDefault = {
	Version = me.Version;
	CacheWarnings = true;
	AchievementsAddFound = nil;
	AlertSoundUnmute = nil;
	AlertSound = nil; -- Default sound
};

do
	local ContinentID, MapID = WORLDMAP_MAELSTROM_ID, 640;
	--- @return The localized name of Deepholm.
	local function FindDeepholm ( ... )
		for ZoneIndex = 1, select( "#", ... ) do
			SetMapZoom( ContinentID, ZoneIndex );
			if ( GetCurrentMapAreaID() == MapID ) then
				return select( ZoneIndex, ... );
			end
		end
	end
	local DEEPHOLM = FindDeepholm( GetMapZones( ContinentID ) );

	me.OptionsCharacterDefault = {
		Version = me.Version;
		NPCs = {
			[ 18684 ] = L.NPCs[ 18684 ]; -- Bro'Gaz the Clanless
			[ 32491 ] = L.NPCs[ 32491 ]; -- Time-Lost Proto Drake
			[ 33776 ] = L.NPCs[ 33776 ]; -- Gondria
			[ 35189 ] = L.NPCs[ 35189 ]; -- Skoll
			[ 38453 ] = L.NPCs[ 38453 ]; -- Arcturis
			[ 49822 ] = L.NPCs[ 49822 ]; -- Jadefang
			[ 49913 ] = L.NPCs[ 49913 ]; -- Lady LaLa
			[ 50005 ] = L.NPCs[ 50005 ]; -- Poseidus
			[ 50009 ] = L.NPCs[ 50009 ]; -- Mobus
			[ 50050 ] = L.NPCs[ 50050 ]; -- Shok'sharak
			[ 50051 ] = L.NPCs[ 50051 ]; -- Ghostcrawler
			[ 50052 ] = L.NPCs[ 50052 ]; -- Burgy Blackheart
			[ 50053 ] = L.NPCs[ 50053 ]; -- Thartuk the Exile
			[ 50056 ] = L.NPCs[ 50056 ]; -- Garr
			[ 50057 ] = L.NPCs[ 50057 ]; -- Blazewing
			[ 50058 ] = L.NPCs[ 50058 ]; -- Terrorpene
			[ 50059 ] = L.NPCs[ 50059 ]; -- Golgarok
			[ 50060 ] = L.NPCs[ 50060 ]; -- Terborus
			[ 50061 ] = L.NPCs[ 50061 ]; -- Xariona
			[ 50062 ] = L.NPCs[ 50062 ]; -- Aeonaxx
			[ 50063 ] = L.NPCs[ 50063 ]; -- Akma'hat
			[ 50064 ] = L.NPCs[ 50064 ]; -- Cyrus the Black
			[ 50065 ] = L.NPCs[ 50065 ]; -- Armagedillo
			[ 50085 ] = L.NPCs[ 50085 ]; -- Overlord Sunderfury
			[ 50086 ] = L.NPCs[ 50086 ]; -- Tarvus the Vile
			[ 50089 ] = L.NPCs[ 50089 ]; -- Julak-Doom
			[ 50138 ] = L.NPCs[ 50138 ]; -- Karoma
			[ 50154 ] = L.NPCs[ 50154 ]; -- Madexx
			[ 50159 ] = L.NPCs[ 50159 ]; -- Sambas
			[ 50409 ] = L.NPCs[ 50409 ]; -- Mysterious Camel Figurine
			[ 50410 ] = L.NPCs[ 50410 ]; -- Mysterious Camel Figurine
			[ 51071 ] = L.NPCs[ 51071 ]; -- Captain Florence
			[ 51079 ] = L.NPCs[ 51079 ]; -- Captain Foulwind
			[ 51401 ] = L.NPCs[ 51401 ]; -- Madexx
			[ 51402 ] = L.NPCs[ 51402 ]; -- Madexx
			[ 51403 ] = L.NPCs[ 51403 ]; -- Madexx
			[ 51404 ] = L.NPCs[ 51404 ]; -- Madexx
		};
		NPCWorldIDs = {
			[ 18684 ] = 3; -- Bro'Gaz the Clanless
			[ 32491 ] = 4; -- Time-Lost Proto Drake
			[ 33776 ] = 4; -- Gondria
			[ 35189 ] = 4; -- Skoll
			[ 38453 ] = 4; -- Arcturis
			[ 49822 ] = DEEPHOLM; -- Jadefang
			[ 49913 ] = 2; -- Lady LaLa
			[ 50005 ] = 2; -- Poseidus
			[ 50009 ] = 2; -- Mobus
			[ 50050 ] = 2; -- Shok'sharak
			[ 50051 ] = 2; -- Ghostcrawler
			[ 50052 ] = 2; -- Burgy Blackheart
			[ 50053 ] = 1; -- Thartuk the Exile
			[ 50056 ] = 1; -- Garr
			[ 50057 ] = 1; -- Blazewing
			[ 50058 ] = 1; -- Terrorpene
			[ 50059 ] = DEEPHOLM; -- Golgarok
			[ 50060 ] = DEEPHOLM; -- Terborus
			[ 50061 ] = DEEPHOLM; -- Xariona
			[ 50062 ] = DEEPHOLM; -- Aeonaxx
			[ 50063 ] = 1; -- Akma'hat
			[ 50064 ] = 1; -- Cyrus the Black
			[ 50065 ] = 1; -- Armagedillo
			[ 50085 ] = 2; -- Overlord Sunderfury
			[ 50086 ] = 2; -- Tarvus the Vile
			[ 50089 ] = 2; -- Julak-Doom
			[ 50138 ] = 2; -- Karoma
			[ 50154 ] = 1; -- Madexx
			[ 50159 ] = 2; -- Sambas
			[ 50409 ] = 1; -- Mysterious Camel Figurine
			[ 50410 ] = 1; -- Mysterious Camel Figurine
			[ 51071 ] = 2; -- Captain Florence
			[ 51079 ] = 2; -- Captain Foulwind
			[ 51401 ] = 1; -- Madexx
			[ 51402 ] = 1; -- Madexx
			[ 51403 ] = 1; -- Madexx
			[ 51404 ] = 1; -- Madexx
		};
		Achievements = {
			[ 1312 ] = true; -- Bloody Rare (Outlands)
			[ 2257 ] = true; -- Frostbitten (Northrend)
		};
	};
end


me.Achievements = { --- Criteria data for each achievement.
	[ 1312 ] = { WorldID = 3; }; -- Bloody Rare (Outlands)
	[ 2257 ] = { WorldID = 4; }; -- Frostbitten (Northrend)
};
do
	local VirtualContinents = { --- Continents without physical maps aren't used.
		[ 5 ] = true; -- The Maelstrom
	};
	me.ContinentNames = { GetMapContinents() };
	for ContinentID in pairs( VirtualContinents ) do
		me.ContinentNames[ ContinentID ] = nil;
	end
	me.ContinentIDs = {}; --- Reverse lookup of me.ContinentNames.
end

me.NpcIDMax = 0xFFFFF; --- Largest ID that will fit in a GUID's 20-bit NPC ID field.
me.Updater.UpdateRate = 0.1;




--- Prints a message in the default chat window.
function me.Print ( Message, Color )
	if ( not Color ) then
		Color = NORMAL_FONT_COLOR;
	end
	DEFAULT_CHAT_FRAME:AddMessage( L.PRINT_FORMAT:format(
		me.Options.PrintTime and date( CHAT_TIMESTAMP_FORMAT or L.TIME_FORMAT ) or "",
		Message ), Color.r, Color.g, Color.b );
end


do
	local Tooltip = CreateFrame( "GameTooltip", "_NPCScanTooltip" );
	-- Add template text lines
	local Text = Tooltip:CreateFontString();
	Tooltip:AddFontStrings( Text, Tooltip:CreateFontString() );
	--- Checks the cache for a given NpcID.
	-- @return Localized name of the NPC if cached, or nil if not.
	function me.TestID ( NpcID )
		Tooltip:SetOwner( WorldFrame, "ANCHOR_NONE" );
		Tooltip:SetHyperlink( ( "unit:0xF53%05X00000000" ):format( NpcID ) );
		if ( Tooltip:IsShown() ) then
			return Text:GetText();
		end
	end
end


local CacheListBuild;
do
	local TempList, AlreadyListed = {}, {};
	--- Compiles a cache list into a printable list string.
	-- @param Relist  True to relist NPC names that have already been printed.
	-- @return List string, or nil if the list was empty.
	function CacheListBuild ( self, Relist )
		if ( next( self ) ) then
			-- Build and sort list
			for NpcID, Name in pairs( self ) do
				if ( Relist or not AlreadyListed[ NpcID ] ) then
					if ( not Relist ) then -- Filtered to show NPCs only once
						AlreadyListed[ NpcID ] = true; -- Don't list again
					end
					-- Add quotes to all entries
					TempList[ #TempList + 1 ] = L.CACHELIST_ENTRY_FORMAT:format( Name );
				end
			end

			wipe( self );
			if ( #TempList > 0 ) then
				sort( TempList );
				local ListString = table.concat( TempList, L.CACHELIST_SEPARATOR );
				wipe( TempList );
				return ListString;
			end
		end
	end
end
local CacheList = {};
do
	--- Fills a cache list with all added NPCs, active or not.
	local function CacheListPopulate ( self )
		for NpcID in pairs( me.OptionsCharacter.NPCs ) do
			self[ NpcID ] = me.TestID( NpcID );
		end
		for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
			for CriteriaID, NpcID in pairs( me.Achievements[ AchievementID ].Criteria ) do
				if ( me.Options.AchievementsAddFound or not select( 3, GetAchievementCriteriaInfo( CriteriaID ) ) ) then -- Not completed
					self[ NpcID ] = me.TestID( NpcID );
				end
			end
		end
	end
	local FirstPrint = true;
	--- Prints a standard message listing cached mobs.
	-- Will also print details about the cache the first time it's called.
	-- @param ForcePrint  Overrides the user's option to not print cache warnings.
	-- @param FullListing  Adds all cached NPCs before printing, active or not.
	-- @return True if list printed.
	function me.CacheListPrint ( ForcePrint, FullListing )
		if ( ForcePrint or me.Options.CacheWarnings ) then
			if ( FullListing ) then
				CacheListPopulate( CacheList );
			end
			local ListString = CacheListBuild( CacheList, ForcePrint or FullListing ); -- Allow printing an NPC a second time if forced or full listing
			if ( ListString ) then
				me.Print( L[ FirstPrint and "CACHED_LONG_FORMAT" or "CACHED_FORMAT" ]:format( ListString ), ForcePrint and RED_FONT_COLOR );
				FirstPrint = false;
				return true;
			end
		else
			wipe( CacheList );
		end
	end
end




local next, assert = next, assert;

local ScanIDs = {}; --- [ NpcID ] = Number of concurrent scans for this ID
--- Begins searching for an NPC.
-- @return True if successfully added.
local function ScanAdd ( NpcID )
	local Name = me.TestID( NpcID );
	if ( Name ) then -- Already seen
		CacheList[ NpcID ] = Name;
	else -- Increment
		if ( ScanIDs[ NpcID ] ) then
			ScanIDs[ NpcID ] = ScanIDs[ NpcID ] + 1;
		else
			if ( not next( ScanIDs ) ) then -- First
				me.Updater:Play();
			end
			ScanIDs[ NpcID ] = 1;
			me.Overlays.Add( NpcID );
		end
		return true; -- Successfully added
	end
end
--- Stops searching for an NPC when nothing is searching for it.
local function ScanRemove ( NpcID )
	local Count = assert( ScanIDs[ NpcID ], "Attempt to remove inactive scan." );
	if ( Count > 1 ) then
		ScanIDs[ NpcID ] = Count - 1;
	else
		ScanIDs[ NpcID ] = nil;
		me.Overlays.Remove( NpcID );
		if ( not next( ScanIDs ) ) then -- Last
			me.Updater:Stop();
		end
	end
end




--- @return True if the given WorldID is active on the current world.
local function IsWorldIDActive ( WorldID )
	return not WorldID or WorldID == me.WorldID; -- False/nil active on all worlds
end

local NPCActivate, NPCDeactivate;
do
	local NPCsActive = {};
	--- Starts actual scan for NPC if on the right world.
	function NPCActivate ( NpcID, WorldID )
		if ( not NPCsActive[ NpcID ] and IsWorldIDActive( WorldID ) and ScanAdd( NpcID ) ) then
			NPCsActive[ NpcID ] = true;
			me.Config.Search.UpdateTab( "NPC" );
			return true; -- Successfully activated
		end
	end
	--- Ends actual scan for NPC.
	function NPCDeactivate ( NpcID )
		if ( NPCsActive[ NpcID ] ) then
			NPCsActive[ NpcID ] = nil;
			ScanRemove( NpcID );
			me.Config.Search.UpdateTab( "NPC" );
			return true; -- Successfully deactivated
		end
	end
	--- @return True if a custom NPC is actively being searched for.
	function me.NPCIsActive ( NpcID )
		return NPCsActive[ NpcID ];
	end
end
--- Adds an NPC name and ID to settings and begins searching.
-- @param NpcID  Numeric ID of the NPC (See Wowhead.com).
-- @param Name  Temporary name to identify this NPC by in the search table.
-- @param WorldID  Number or localized string WorldID to limit this search to.
-- @return True if custom NPC added.
function me.NPCAdd ( NpcID, Name, WorldID )
	NpcID = assert( tonumber( NpcID ), "NpcID must be numeric." );
	local Options = me.OptionsCharacter;
	if ( not Options.NPCs[ NpcID ] ) then
		assert( type( Name ) == "string", "Name must be a string." );
		assert( WorldID == nil or type( WorldID ) == "string" or type( WorldID ) == "number", "Invalid WorldID." );
		Options.NPCs[ NpcID ], Options.NPCWorldIDs[ NpcID ] = Name, WorldID;
		if ( not NPCActivate( NpcID, WorldID ) ) then -- Didn't activate
			me.Config.Search.UpdateTab( "NPC" ); -- Just add row
		end
		return true;
	end
end
--- Removes an NPC from settings and stops searching for it.
-- @param NpcID  Numeric ID of the NPC.
-- @return True if custom NPC removed.
function me.NPCRemove ( NpcID )
	NpcID = tonumber( NpcID );
	local Options = me.OptionsCharacter;
	if ( Options.NPCs[ NpcID ] ) then
		Options.NPCs[ NpcID ], Options.NPCWorldIDs[ NpcID ] = nil;
		if ( not NPCDeactivate( NpcID ) ) then -- Wasn't active
			me.Config.Search.UpdateTab( "NPC" ); -- Just remove row
		end
		return true;
	end
end




--- Starts searching for an achievement's NPC if it meets all settings.
local function AchievementNPCActivate ( Achievement, NpcID, CriteriaID )
	if ( Achievement.Active and not Achievement.NPCsActive[ NpcID ]
		and ( me.Options.AchievementsAddFound or not select( 3, GetAchievementCriteriaInfo( CriteriaID ) ) ) -- Not completed
		and ScanAdd( NpcID )
	) then
		Achievement.NPCsActive[ NpcID ] = CriteriaID;
		me.Config.Search.UpdateTab( Achievement.ID );
		return true;
	end
end
--- Stops searching for an achievement's NPC.
local function AchievementNPCDeactivate ( Achievement, NpcID )
	if ( Achievement.NPCsActive[ NpcID ] ) then
		Achievement.NPCsActive[ NpcID ] = nil;
		ScanRemove( NpcID );
		me.Config.Search.UpdateTab( Achievement.ID );
		return true;
	end
end
--- Starts actual scans for achievement NPCs if on the right world.
local function AchievementActivate ( Achievement )
	if ( not Achievement.Active and IsWorldIDActive( Achievement.WorldID ) ) then
		Achievement.Active = true;
		for CriteriaID, NpcID in pairs( Achievement.Criteria ) do
			AchievementNPCActivate( Achievement, NpcID, CriteriaID );
		end
		return true;
	end
end
-- Ends actual scans for achievement NPCs.
local function AchievementDeactivate ( Achievement )
	if ( Achievement.Active ) then
		Achievement.Active = nil;
		for NpcID in pairs( Achievement.NPCsActive ) do
			AchievementNPCDeactivate( Achievement, NpcID );
		end
		return true;
	end
end
--- @param Achievement  Achievement data table from me.Achievements.
-- @return True if the achievement NPC is being searched for.
function me.AchievementNPCIsActive ( Achievement, NpcID )
	return Achievement.NPCsActive[ NpcID ] ~= nil;
end
--- Adds a kill-related achievement to track.
-- @param AchievementID  Numeric ID of achievement.
-- @return True if achievement added.
function me.AchievementAdd ( AchievementID )
	AchievementID = assert( tonumber( AchievementID ), "AchievementID must be numeric." );
	local Achievement = me.Achievements[ AchievementID ];
	if ( Achievement and not me.OptionsCharacter.Achievements[ AchievementID ] ) then
		if ( not next( me.OptionsCharacter.Achievements ) ) then -- First
			me.Frame:RegisterEvent( "ACHIEVEMENT_EARNED" );
			me.Frame:RegisterEvent( "CRITERIA_UPDATE" );
		end
		me.OptionsCharacter.Achievements[ AchievementID ] = true;
		me.Config.Search.AchievementSetEnabled( AchievementID, true );
		AchievementActivate( Achievement );
		return true;
	end
end
--- Removes an achievement from settings and stops tracking it.
-- @param AchievementID  Numeric ID of achievement.
-- @return True if achievement removed.
function me.AchievementRemove ( AchievementID )
	if ( me.OptionsCharacter.Achievements[ AchievementID ] ) then
		AchievementDeactivate( me.Achievements[ AchievementID ] );
		me.OptionsCharacter.Achievements[ AchievementID ] = nil;
		if ( not next( me.OptionsCharacter.Achievements ) ) then -- Last
			me.Frame:UnregisterEvent( "ACHIEVEMENT_EARNED" );
			me.Frame:UnregisterEvent( "CRITERIA_UPDATE" );
		end
		me.Config.Search.AchievementSetEnabled( AchievementID, false );
		return true;
	end
end




--- Enables printing cache lists on login.
-- @return True if changed.
function me.SetCacheWarnings ( Enable )
	if ( not Enable ~= not me.Options.CacheWarnings ) then
		me.Options.CacheWarnings = Enable or nil;

		me.Config.CacheWarnings:SetChecked( Enable );
		return true;
	end
end
--- Enables adding a timestamp to printed messages.
-- @return True if changed.
function me.SetPrintTime ( Enable )
	if ( not Enable ~= not me.Options.PrintTime ) then
		me.Options.PrintTime = Enable or nil;

		me.Config.PrintTime:SetChecked( Enable );
		return true;
	end
end
--- Enables tracking of unneeded achievement NPCs.
-- @return True if changed.
function me.SetAchievementsAddFound ( Enable )
	if ( not Enable ~= not me.Options.AchievementsAddFound ) then
		me.Options.AchievementsAddFound = Enable or nil;
		me.Config.Search.AddFoundCheckbox:SetChecked( Enable );

		for _, Achievement in pairs( me.Achievements ) do
			if ( AchievementDeactivate( Achievement ) ) then -- Was active
				AchievementActivate( Achievement );
			end
		end
		return true;
	end
end
--- Enables unmuting sound to play found alerts.
-- @return True if changed.
function me.SetAlertSoundUnmute ( Enable )
	if ( not Enable ~= not me.Options.AlertSoundUnmute ) then
		me.Options.AlertSoundUnmute = Enable or nil;

		me.Config.AlertSoundUnmute:SetChecked( Enable );
		return true;
	end
end
--- Sets the sound to play when NPCs are found.
-- @return True if changed.
function me.SetAlertSound ( AlertSound )
	assert( AlertSound == nil or type( AlertSound ) == "string", "AlertSound must be a string or nil." );
	if ( AlertSound ~= me.Options.AlertSound ) then
		me.Options.AlertSound = AlertSound;

		UIDropDownMenu_SetText( me.Config.AlertSound, AlertSound == nil and L.CONFIG_ALERT_SOUND_DEFAULT or AlertSound );
		return true;
	end
end




local IsDefaultNPCValid;
do
	local IsHunter = select( 2, UnitClass( "player" ) ) == "HUNTER";
	local TamableExceptions = {
		[ 49822 ] = true; -- Jadefang drops a pet
	};
	--- @return True if NpcID should be a default for this character.
	function IsDefaultNPCValid ( NpcID )
		return IsHunter or not me.TamableIDs[ NpcID ] or TamableExceptions[ NpcID ];
	end
end
--- Resets the scanning list and reloads it from saved settings.
function me.Synchronize ( Options, OptionsCharacter )
	-- Load defaults if settings omitted
	local IsDefaultScan;
	if ( not Options ) then
		Options = me.OptionsDefault;
	end
	if ( not OptionsCharacter ) then
		OptionsCharacter, IsDefaultScan = me.OptionsCharacterDefault, true;
	end

	-- Clear all scans
	for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
		me.AchievementRemove( AchievementID );
	end
	for NpcID in pairs( me.OptionsCharacter.NPCs ) do
		me.NPCRemove( NpcID );
	end
	assert( not next( ScanIDs ), "Orphan NpcIDs in scan pool!" );

	me.SetCacheWarnings( Options.CacheWarnings );
	me.SetPrintTime( Options.PrintTime );
	me.SetAchievementsAddFound( Options.AchievementsAddFound );
	me.SetAlertSoundUnmute( Options.AlertSoundUnmute );
	me.SetAlertSound( Options.AlertSound );

	local AddAllDefaults = IsShiftKeyDown();
	for NpcID, Name in pairs( OptionsCharacter.NPCs ) do
		-- If defaults, only add tamable custom mobs if the player is a hunter
		if ( AddAllDefaults or not IsDefaultScan or IsDefaultNPCValid( NpcID ) ) then
			me.NPCAdd( NpcID, Name, OptionsCharacter.NPCWorldIDs[ NpcID ] );
		end
	end
	for AchievementID in pairs( me.Achievements ) do
		-- If defaults, don't enable completed achievements unless explicitly allowed
		if ( OptionsCharacter.Achievements[ AchievementID ] and (
			not IsDefaultScan or Options.AchievementsAddFound or not select( 4, GetAchievementInfo( AchievementID ) ) -- Not completed
		) ) then
			me.AchievementAdd( AchievementID );
		end
	end
	me.CacheListPrint( false, true ); -- Populates cache list with inactive mobs too before printing
end




do
	local PetList = {};

	--- Prints the list of cached pets when leaving a city or inn.
	function me.Frame:PLAYER_UPDATE_RESTING ()
		if ( not IsResting() and next( PetList ) ) then
			if ( me.Options.CacheWarnings ) then
				local ListString = CacheListBuild( PetList );
				if ( ListString ) then
					me.Print( L.CACHED_PET_RESTING_FORMAT:format( ListString ), RED_FONT_COLOR );
				end
			else
				wipe( PetList );
			end
		end
	end

	--- @return True if the tamable mob is in its correct zone, else false with an optional reason string.
	local function OnFoundTamable ( NpcID, Name )
		local ExpectedZone = me.TamableIDs[ NpcID ];
		local ZoneIDBackup = GetCurrentMapAreaID();
		SetMapToCurrentZone();

		local InCorrectZone, InvalidReason =
			ExpectedZone == true -- Expected zone is unknown (instance mob, etc.)
			or ExpectedZone == GetCurrentMapAreaID();

		if ( not InCorrectZone ) then
			if ( IsResting() ) then -- Assume any tamable mob found in a city/inn is a hunter pet
				PetList[ NpcID ] = Name;  -- Suppress error message until the player stops resting
			else
				-- Get details about expected zone
				local ExpectedZoneName;
				SetMapByID( ExpectedZone );
				local Continent = GetCurrentMapContinent();
				if ( Continent >= 1 ) then
					local Zone = GetCurrentMapZone();
					if ( Zone == 0 ) then
						ExpectedZoneName = select( Continent, GetMapContinents() );
					else
						ExpectedZoneName = select( Zone, GetMapZones( Continent ) );
					end
				end
				InvalidReason = L.FOUND_TAMABLE_WRONGZONE_FORMAT:format(
					Name, GetZoneText(), ExpectedZoneName or L.FOUND_ZONE_UNKNOWN, ExpectedZone );
			end
		end

		SetMapByID( ZoneIDBackup ); -- Restore previous map view
		return InCorrectZone, InvalidReason;
	end
	--- Validates found mobs before showing alerts.
	local function OnFound ( NpcID, Name )
		-- Disable active scans
		NPCDeactivate( NpcID );
		for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
			AchievementNPCDeactivate( me.Achievements[ AchievementID ], NpcID );
		end

		local Valid, InvalidReason = true;
		local Tamable = me.TamableIDs[ NpcID ];
		if ( Tamable ) then
			Valid, InvalidReason = OnFoundTamable( NpcID, Name );
		end

		if ( Valid ) then
			me.Print( L[ Tamable and "FOUND_TAMABLE_FORMAT" or "FOUND_FORMAT" ]:format( Name ), GREEN_FONT_COLOR );
			me.Button:SetNPC( NpcID, Name ); -- Sends added and found overlay messages
		elseif ( InvalidReason ) then
			me.Print( InvalidReason );
		end
	end

	local pairs = pairs;
	local GetAchievementCriteriaInfo = GetAchievementCriteriaInfo;
	--- Scans all active criteria and removes any completed NPCs.
	local function AchievementCriteriaUpdate ()
		if ( not me.Options.AchievementsAddFound ) then
			for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
				local Achievement = me.Achievements[ AchievementID ];
				for NpcID, CriteriaID in pairs( Achievement.NPCsActive ) do
					local _, _, Complete = GetAchievementCriteriaInfo( CriteriaID );
					if ( Complete ) then
						AchievementNPCDeactivate( Achievement, NpcID );
					end
				end
			end
		end
	end
	local CriteriaUpdated = false;
	--- Stops tracking individual achievement NPCs when the player gets kill credit.
	function me.Frame:CRITERIA_UPDATE ()
		CriteriaUpdated = true;
	end

	--- Scans all NPCs on a timer and alerts if any are found.
	function me.Updater:OnLoop ()
		if ( CriteriaUpdated ) then -- CRITERIA_UPDATE bucket
			CriteriaUpdated = false;
			AchievementCriteriaUpdate();
		end

		for NpcID in pairs( ScanIDs ) do
			local Name = me.TestID( NpcID );
			if ( Name ) then
				OnFound( NpcID, Name );
			end
		end
	end
end
if ( select( 2, UnitClass( "player" ) ) == "HUNTER" ) then
	local StableUpdater = CreateFrame( "Frame" );

	local StabledList = {};
	--- Stops scans for stabled hunter pets before a bogus alert can fire.
	function me.Frame:PET_STABLE_UPDATE ()
		for NpcID in pairs( ScanIDs ) do
			local Name = me.TestID( NpcID );
			if ( Name ) then
				StabledList[ NpcID ] = Name;
				NPCDeactivate( NpcID );
				for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
					AchievementNPCDeactivate( me.Achievements[ AchievementID ], NpcID );
				end
			end
		end
		StableUpdater:Show();
	end
	--- Bucket to print cached stabled pets on one line.
	function StableUpdater:OnUpdate ()
		self:Hide();
		if ( me.Options.CacheWarnings ) then
			local ListString = CacheListBuild( StabledList );
			if ( ListString ) then
				me.Print( L.CACHED_STABLED_FORMAT:format( ListString ) );
			end
		else
			wipe( StabledList );
		end
	end

	StableUpdater:Hide();
	StableUpdater:SetScript( "OnUpdate", StableUpdater.OnUpdate );
	me.Frame:RegisterEvent( "PET_STABLE_UPDATE" );

	local Backup = GetStablePetInfo;
	--- Prevents the pet UI from querying (and caching) pets until actually viewing the stables.
	-- @param Override  Forces a normal query even if the stables aren't open.
	function GetStablePetInfo ( Slot, Override, ... )
		if ( Override or IsAtStableMaster() ) then
			return Backup( Slot, Override, ... );
		end
	end
end




--- Loads defaults, validates settings, and starts scan.
-- Used instead of ADDON_LOADED to give overlay mods a chance to load and register for messages.
function me.Frame:PLAYER_LOGIN ( Event )
	self[ Event ] = nil;

	local Options, OptionsCharacter = _NPCScanOptions, _NPCScanOptionsCharacter;
	_NPCScanOptions, _NPCScanOptionsCharacter = me.Options, me.OptionsCharacter;

	-- Update settings incrementally
	if ( Options and Options.Version ~= me.Version ) then
		if ( Options.Version == "3.0.9.2" ) then -- 3.1.0.1: Added options for finding already found and tamable mobs
			Options.CacheWarnings = true;
			Options.Version = "3.1.0.1";
		end
		Options.Version = me.Version;
	end
	-- Character settings
	if ( OptionsCharacter and OptionsCharacter.Version ~= me.Version ) then
		local Version = OptionsCharacter.Version;

		local WorldIDs = me.OptionsCharacterDefault.NPCWorldIDs;
		--- Add NpcID if not already being searched for.
		local function AddDefault ( NpcID )
			if ( not OptionsCharacter.NPCs[ NpcID ] -- Not already searched for
				and IsDefaultNPCValid( NpcID )
			) then
				OptionsCharacter.NPCs[ NpcID ] = L.NPCs[ NpcID ];
				OptionsCharacter.NPCWorldIDs[ NpcID ] = WorldIDs[ NpcID ];
			end
		end

		if ( Version == "3.0.9.2" ) then -- 3.1.0.1: Remove NPCs that are duplicated by achievements
			local NPCs = OptionsCharacter.IDs;
			OptionsCharacter.IDs = nil;
			OptionsCharacter.NPCs = NPCs;
			OptionsCharacter.Achievements = {};
			local AchievementNPCs = {};
			for AchievementID, Achievement in pairs( me.Achievements ) do
				for _, NpcID in pairs( Achievement.Criteria ) do
					AchievementNPCs[ NpcID ] = AchievementID;
				end
			end
			for Name, NpcID in pairs( NPCs ) do
				if ( AchievementNPCs[ NpcID ] ) then
					NPCs[ Name ] = nil;
					OptionsCharacter.Achievements[ AchievementNPCs[ NpcID ] ] = true;
				end
			end
			Version = "3.1.0.1";
		end
		if ( Version == "3.1.0.1" or Version == "3.2.0.1" or Version == "3.2.0.2" ) then
			-- 3.2.0.3: Added default scan for Skoll
			OptionsCharacter.NPCs[ L.NPCs[ 35189 ] ] = 35189;
			Version = "3.2.0.3";
		end
		if ( "3.2.0.3" <= Version and Version <= "3.3.0.1" ) then
			-- 3.3.0.2: Added default scan for Arcturis
			OptionsCharacter.NPCs[ L.NPCs[ 38453 ] ] = 38453;
			Version = "3.3.0.2";
		end
		if ( Version == "3.3.0.2" or Version == "3.3.0.3" or Version == "3.3.0.4" ) then
			-- 3.3.5.1: Custom NPC scans are indexed by ID instead of name, and can now be map-specific
			local DefaultWorldIDs = me.OptionsCharacterDefault.NPCWorldIDs;
			local NPCsNew, NPCWorldIDs = {}, {};
			for Name, NpcID in pairs( OptionsCharacter.NPCs ) do
				NPCsNew[ NpcID ] = Name;
				NPCWorldIDs[ NpcID ] = DefaultWorldIDs[ NpcID ];
			end
			OptionsCharacter.NPCs, OptionsCharacter.NPCWorldIDs = NPCsNew, NPCWorldIDs;
			Version = "3.3.5.1";
		end
		if ( Version < "4.0.3.1" ) then
			-- 4.0.3.1: Added default scans for Cataclysm rares
			AddDefault( 49913 ); -- Lady LaLa
			AddDefault( 50005 ); -- Poseidus
			AddDefault( 50009 ); -- Mobus
			AddDefault( 50050 ); -- Shok'sharak
			AddDefault( 50051 ); -- Ghostcrawler
			AddDefault( 50052 ); -- Burgy Blackheart
			AddDefault( 50053 ); -- Thartuk the Exile
			AddDefault( 50056 ); -- Garr
			AddDefault( 50057 ); -- Blazewing
			AddDefault( 50058 ); -- Terrorpene
			AddDefault( 50059 ); -- Golgarok
			AddDefault( 50060 ); -- Terborus
			AddDefault( 50061 ); -- Xariona
			AddDefault( 50062 ); -- Aeonaxx
			AddDefault( 50063 ); -- Akma'hat
			AddDefault( 50064 ); -- Cyrus the Black
			AddDefault( 50065 ); -- Armagedillo
			AddDefault( 50085 ); -- Overlord Sunderfury
			AddDefault( 50086 ); -- Tarvus the Vile
			AddDefault( 50089 ); -- Julak-Doom
			AddDefault( 50138 ); -- Karoma
			AddDefault( 50154 ); -- Madexx
			AddDefault( 50159 ); -- Sambas
			AddDefault( 50409 ); -- Mysterious Camel Figurine
			AddDefault( 50410 ); -- Mysterious Camel Figurine
			AddDefault( 51071 ); -- Captain Florence
			AddDefault( 51079 ); -- Captain Foulwind
			AddDefault( 51401 ); -- Madexx
			AddDefault( 51402 ); -- Madexx
			AddDefault( 51403 ); -- Madexx
			AddDefault( 51404 ); -- Madexx
			Version = "4.0.3.1";
		end
		if ( Version < "4.0.3.3" ) then
			-- 4.0.3.3: Fixed omission of Jadefang.
			AddDefault( 49822 ); -- Jadefang
			Version = "4.0.3.3";
		end
		OptionsCharacter.Version = me.Version;
	end

	me.Overlays.Register();
	me.Synchronize( Options, OptionsCharacter ); -- Loads defaults if either are nil
end
do
	local FirstWorld = true;
	--- Starts world-specific scans when entering a world.
	function me.Frame:PLAYER_ENTERING_WORLD ()
		-- Print cached pets if player ported out of a city
		self:PLAYER_UPDATE_RESTING();

		-- Since real MapIDs aren't available to addons, a "WorldID" is a universal ContinentID or the map's localized name.
		local MapName = GetInstanceInfo();
		me.WorldID = me.ContinentIDs[ MapName ] or MapName;

		-- Activate scans on this world
		for NpcID, WorldID in pairs( me.OptionsCharacter.NPCWorldIDs ) do
			NPCActivate( NpcID, WorldID );
		end
		for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
			local Achievement = me.Achievements[ AchievementID ];
			if ( Achievement.WorldID ) then
				AchievementActivate( Achievement );
			end
		end

		if ( FirstWorld or not me.Options.CacheWarnings ) then -- Full listing of cached mobs gets printed on login
			FirstWorld = false;
			wipe( CacheList );
		else -- Print list of cached mobs specific to new world
			local ListString = CacheListBuild( CacheList );
			if ( ListString ) then
				me.Print( L.CACHED_WORLD_FORMAT:format( ListString, MapName ) );
			end
		end
	end
end
--- Stops world-specific scans when leaving a world.
function me.Frame:PLAYER_LEAVING_WORLD ()
	-- Stop scans that were only active on the previous world
	for NpcID in pairs( me.OptionsCharacter.NPCWorldIDs ) do
		NPCDeactivate( NpcID );
	end
	for AchievementID in pairs( me.OptionsCharacter.Achievements ) do
		local Achievement = me.Achievements[ AchievementID ];
		if ( Achievement.WorldID ) then
			AchievementDeactivate( Achievement );
		end
	end
	me.WorldID = nil;
end
--- Stops tracking achievements when they finish.
function me.Frame:ACHIEVEMENT_EARNED ( _, AchievementID )
	if ( not me.Options.AchievementsAddFound ) then
		me.AchievementRemove( AchievementID );
	end
end
--- Sets the update handler only after zone info is known.
function me.Frame:ZONE_CHANGED_NEW_AREA ( Event )
	self:UnregisterEvent( Event );
	self[ Event ] = nil;

	me.Updater:SetScript( "OnLoop", me.Updater.OnLoop );
end
--- Global event handler.
function me.Frame:OnEvent ( Event, ... )
	if ( self[ Event ] ) then
		return self[ Event ]( self, Event, ... );
	end
end




--- Slash command chat handler to open the options pane and manage the NPC list.
function me.SlashCommand ( Input )
	local Command, Arguments = Input:match( "^(%S+)%s*(.-)%s*$" );
	if ( Command ) then
		Command = Command:upper();
		if ( Command == L.CMD_ADD ) then
			local ID, Name = Arguments:match( "^(%d+)%s+(.+)$" );
			if ( ID ) then
				ID = tonumber( ID );
				me.NPCRemove( ID );
				if ( me.NPCAdd( ID, Name ) ) then
					me.CacheListPrint( true );
				end
				return;
			end
		elseif ( Command == L.CMD_REMOVE ) then
			local ID = tonumber( Arguments );
			if ( not ID ) then -- Search custom names
				for NpcID, Name in pairs( me.OptionsCharacter.NPCs ) do
					if ( Name == Arguments ) then
						ID = NpcID;
						break;
					end
				end
			end
			if ( not me.NPCRemove( ID ) ) then
				me.Print( L.CMD_REMOVENOTFOUND_FORMAT:format( Arguments ), RED_FONT_COLOR );
			end
			return;
		elseif ( Command == L.CMD_CACHE ) then -- Force print full cache list
			if ( not me.CacheListPrint( true, true ) ) then -- Nothing in cache
				me.Print( L.CMD_CACHE_EMPTY, GREEN_FONT_COLOR );
			end
			return;
		end
		-- Invalid subcommand
		me.Print( L.CMD_HELP );

	else -- No subcommand
		InterfaceOptionsFrame_OpenToCategory( me.Config.Search );
	end
end




-- Create reverse lookup of continent names
for Index, Name in pairs( me.ContinentNames ) do
	me.ContinentIDs[ Name ] = Index;
end
-- Save achievement criteria data
for AchievementID, Achievement in pairs( me.Achievements ) do
	Achievement.ID = AchievementID;
	Achievement.Criteria = {}; -- [ CriteriaID ] = NpcID;
	Achievement.NPCsActive = {}; -- [ NpcID ] = CriteriaID;
	for Criteria = 1, GetAchievementNumCriteria( AchievementID ) do
		local _, CriteriaType, _, _, _, _, _, AssetID, _, CriteriaID = GetAchievementCriteriaInfo( AchievementID, Criteria );
		if ( CriteriaType == 0 ) then -- Mob kill type
			Achievement.Criteria[ CriteriaID ] = AssetID;
		end
	end
end


local Frame = me.Frame;
Frame:SetScript( "OnEvent", Frame.OnEvent );
if ( not IsLoggedIn() ) then
	Frame:RegisterEvent( "PLAYER_LOGIN" );
else
	Frame:PLAYER_LOGIN( "PLAYER_LOGIN" );
end
Frame:RegisterEvent( "PLAYER_ENTERING_WORLD" );
Frame:RegisterEvent( "PLAYER_LEAVING_WORLD" );
Frame:RegisterEvent( "PLAYER_UPDATE_RESTING" );

me.Updater:CreateAnimation( "Animation" ):SetDuration( me.Updater.UpdateRate );
me.Updater:SetLooping( "REPEAT" );
-- Set update handler after zone info loads
if ( GetZoneText() == "" ) then -- Zone information unknown (initial login)
	Frame:RegisterEvent( "ZONE_CHANGED_NEW_AREA" );
else -- Zone information is known
	Frame:ZONE_CHANGED_NEW_AREA( "ZONE_CHANGED_NEW_AREA" );
end

SlashCmdList[ "_NPCSCAN" ] = me.SlashCommand;