// Emacs style mode select   -*- C++ -*-
//-----------------------------------------------------------------------------
//
// $Id: 26ecc29af5ee92226d2246d3d8734b1352f0c7a1 $
//
// Copyright (C) 1998-2006 by Randy Heit (ZDoom).
// Copyright (C) 2006-2026 by The Odamex Team.
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// DESCRIPTION:
//  "Horde" game mode.
//
//-----------------------------------------------------------------------------

#include "odamex.h"

#include <math.h>

#include "p_horde.h"

#include "c_dispatch.h"
#include "d_player.h"
#include "doomstat.h"
#include "g_gametype.h"
#include "g_horde.h"
#include "i_net.h"
#include "m_random.h"
#include "p_hordespawn.h"
#include "p_local.h"
#include "p_tick.h"
#include "s_sound.h"
#include "svc_message.h"

#if defined(SERVER_APP)
#include "sv_main.h"
#endif

EXTERN_CVAR(g_horde_waves)
EXTERN_CVAR(g_lives)
EXTERN_CVAR(g_horde_spawnempty_min)
EXTERN_CVAR(g_horde_spawnempty_max)
EXTERN_CVAR(g_horde_spawnfull_min)
EXTERN_CVAR(g_horde_spawnfull_max)
EXTERN_CVAR(g_horde_extralife)
EXTERN_CVAR(g_horde_resurrect)
EXTERN_CVAR(sv_nomonsters)

void A_PainDie(AActor* actor);

constexpr int HORDE_STARTING_TICS = TICRATE * 3;

/**
 * @brief Garbage-collector for Horde corpses.
 *
 * @details We want to destroy corpses from previous waves so the map isn't
 *          overwhelmed with corpses.
 */
class corpseCollector_t
{
	typedef std::queue<AActor::AActorPtr> AActorQueue;

	AActorQueue m_corpses;
	size_t m_waveStart;
	size_t m_added;
	size_t m_removed;

	void popCorpse()
	{
		m_corpses.pop();
		m_removed += 1;
	}

  public:
	corpseCollector_t() : m_waveStart(0), m_added(0), m_removed(0) { }
	void clear()
	{
		m_corpses = AActorQueue();
		m_waveStart = 0;
		m_added = 0;
		m_removed = 0;
	}
	void pushCorpse(AActor* ptr)
	{
		m_corpses.push(ptr->ptr());
		m_added += 1;
	}
	void startWave() { m_waveStart = m_added; }
	void tick()
	{
		if (m_removed >= m_waveStart)
		{
			// Everything in the queue is from the current wave.
			return;
		}

		AActor::AActorPtr& actor = m_corpses.front();
		if (!actor)
		{
			// Actor is destroyed, just pop it.
			popCorpse();
			return;
		}

		if (actor->health > 0)
		{
			// Actor is not a corpse anymore, just pop it.
			popCorpse();
			return;
		}

		// Destroy the actor before we pop it.
		actor->Destroy();
		popCorpse();
	}
};

/**
 * @brief Wake up all the passed monsters.
 *
 * @details This is in a separate function because if we activated monsters
 *          immediately upon spawning, their activation state causes them to
 *          move a tiny bit, which can spawn block other monsters.
 */
static void ActivateMonsters(AActors& mobjs)
{
	for (auto& mo : mobjs)
	{
		// Add them health pool now, since the function to do this is
		// in this TU anyway.
		P_AddHealthPool(mo);

		// Hate a random player.
		PlayerResults targets = PlayerQuery().hasHealth().execute();
		if (targets.count > 0)
		{
			const size_t idx = P_RandomInt(targets.count);
			mo->target = targets.players.at(idx)->mo->ptr();
			P_SetMobjState(mo, mo->info->seestate, true);
		}

		// Play the see sound if we have one.
		if (mo->info->seesound)
		{
			char sound[MAX_SNDNAME];

			M_StringCopy(sound, mo->info->seesound, MAX_SNDNAME);

			if (sound[strlen(sound) - 1] == '1')
			{
				sound[strlen(sound) - 1] = P_Random(mo) % 3 + '1';
				if (S_FindSound(sound) == -1)
					sound[strlen(sound) - 1] = '1';
			}

			S_NetSound(mo, CHAN_VOICE, sound, ATTN_NORM);
		}
	}
}

//static const char* HordeStateStr(const hordeState_e state)
//{
//	switch (state)
//	{
//	case HS_STARTING:
//		return "HS_STARTING";
//	case HS_PRESSURE:
//		return "HS_PRESSURE";
//	case HS_RELAX:
//		return "HS_RELAX";
//	case HS_WANTBOSS:
//		return "HS_WANTBOSS";
//	default:
//		return "UNKNOWN";
//	}
//}

class HordeState
{
	hordeState_e m_state = HS_STARTING;
	int m_wave = 0;
	int m_waveTime = 0;
	int m_bossTime = 0; // If == waveTime, boss not spawned.
	size_t m_defineID = 0;
	int m_spawnedHealth = 0;
	int m_killedHealth = 0;
	int m_bossHealth = 0;
	int m_bossDamage = 0;
	int m_waveStartHealth = 0;
	AActors m_bosses{};
	hordeRecipe_t m_bossRecipe{};
	int m_nextSpawn = 0;
	int m_nextPowerup = 0;
	corpseCollector_t m_corpses{};
	mobjCounts_t m_monsterCounts{};
	mobjCounts_t m_bossCounts{};

	/**
	 * @brief Returns number of bosses currently alive.
	 */
	size_t countLivingBosses() {
		return std::count_if(m_bosses.begin(), m_bosses.end(), [](const auto& boss){ return boss && boss->health > 0; });
	}

	/**
	 * @brief Set the given state.
	 */
	void setState(const hordeState_e state)
	{
		m_state = state;

		if (m_state == HS_WANTBOSS && m_bossTime == m_waveTime)
		{
			// Do the boss intro fanfare.
			SV_BroadcastPrintFmt("The floor trembles as the boss of the wave arrives.\n");
			S_NetSound(NULL, CHAN_GAMEINFO, "misc/horde/boss", ATTN_NONE);
			m_bossTime = ::level.time;
		}
	}

  public:
	HordeState() = default;

	/**
	 * @brief Reset director state.
	 */
	void reset(bool printWave)
	{
		setState(HS_STARTING);
		m_wave = 1;
		m_waveTime = ::level.time;
		m_bossTime = ::level.time;
		m_defineID = P_HordePickDefine(m_wave, ::g_horde_waves);
		m_spawnedHealth = 0;
		m_killedHealth = 0;
		m_bossHealth = 0;
		m_bossDamage = 0;
		m_waveStartHealth = 0;
		m_bosses.clear();
		m_bossRecipe.clear();
		m_nextSpawn = ::level.time;
		m_nextPowerup = ::level.time + (30 * TICRATE);
		m_corpses.clear();
		m_monsterCounts.clear();
		m_bossCounts.clear();

		// Avoid printing wave name on boot and map switch.
		if (printWave)
		{
			SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
			                     G_HordeDefine(m_defineID).name);
		}
	}

	/**
	 * @brief Start the given wave.
	 */
	void nextWave()
	{
		// The server can have lives, and if that's the case we want
		// to bring back dead players.
		if (G_IsLivesGame())
		{
			// Give all ingame players an extra life for beating the wave.
			PlayersView ingame = PlayerQuery().execute().players;
			for (const auto& player : ingame)
			{
				// Dead players are reborn with a message.
				if (player->lives <= 0)
				{
					G_ResetLastPlayer();
					player->playerstate = PST_REBORN;
					SV_BroadcastPrintFmt("{} gets a new lease on life.\n",
					                     player->userinfo.netname);

					// Send a res sound directly to this player.
					S_PlayerSound(player, NULL, CHAN_INTERFACE, "misc/plraise",
					              ATTN_NONE);
				}
				// Give everyone an extra life.
				if (player->lives < g_lives)
				{
					player->lives += 1;
					MSG_WriteSVC(&player->client.reliablebuf, SVC_PlayerInfo(*player));
					MSG_BroadcastSVC(CLBUF_RELIABLE,
					                 SVC_PlayerMembers(*player, SVC_PM_LIVES),
					                 player->id);
				}
			}

		#ifdef SERVER_APP
			// Service the join queue and give all of the freshly-ingame
			// players a single life to start with.
			PlayersView queued = SpecQuery().onlyInQueue().execute();
			SV_UpdatePlayerQueuePositions(G_CanJoinGameStart, NULL);
			for (const auto& player : queued)
			{
				player->lives = 1;
				MSG_WriteSVC(&player->client.reliablebuf, SVC_PlayerInfo(*player));
				MSG_BroadcastSVC(CLBUF_RELIABLE,
				                 SVC_PlayerMembers(*player, SVC_PM_LIVES), player->id);
			}
		#endif
		}


		if (::g_horde_waves && m_wave >= ::g_horde_waves)
		{
			// All monsters explode!  Woo!
			AActor* actor;
			TThinkerIterator<AActor> iterator;
			while ((actor = iterator.Next()))
			{
				if (actor->oflags & MFO_HEALTHPOOL || actor->type == MT_SKULL)
				{
					// [AM] Mostly copypasted from the massacre cheat.
					if (actor->health > 0)
					{
						P_DamageMobj(actor, NULL, NULL, 10000, MOD_UNKNOWN);
					}
					if (actor->type == MT_PAIN)
					{
						A_PainDie(actor);
						P_SetMobjState(actor, S_PAIN_DIE6);
					}
				}
			}

			G_EndGame();
			return;
		}

		setState(HS_STARTING);
		m_wave += 1;
		m_waveTime = ::level.time;
		m_bossTime = ::level.time;
		m_defineID = P_HordePickDefine(m_wave, ::g_horde_waves);
		m_waveStartHealth = m_killedHealth;
		m_bossHealth = 0;
		m_bossDamage = 0;
		m_bosses.clear();
		m_bossRecipe.clear();
		m_corpses.startWave();
		m_bossCounts.clear();

		SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
		                     G_HordeDefine(m_defineID).name);
	}

	/**
	 * @brief Force a wave with a partial name match.
	 *
	 * @param name Partial name to match against.
	 * @return True if the wave was forced, otherwise false.
	 */
	bool forceWave(const std::string& name)
	{
		size_t defineID;
		if (!P_HordeDefineNamed(defineID, name))
			return false;

		setState(HS_STARTING);
		m_waveTime = ::level.time;
		m_bossTime = ::level.time;
		m_defineID = defineID;
		m_waveStartHealth = m_killedHealth;
		m_bossHealth = 0;
		m_bossDamage = 0;
		m_bosses.clear();
		m_bossRecipe.clear();
		m_corpses.startWave();
		m_bossCounts.clear();

		SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
		                     G_HordeDefine(m_defineID).name);
		return true;
	}

	/**
	 * @brief Force the boss to spawn.
	 *
	 * @return True if the state was switched successfully.
	 */
	bool forceBoss()
	{
		if (m_bosses.empty())
		{
			setState(HS_WANTBOSS);
			return true;
		}
		return false;
	}

	/**
	 * @brief Serialize horde data into POD struct.
	 */
	[[nodiscard]]
	hordeInfo_t serialize() const
	{
		hordeInfo_t info;
		info.state = m_state;
		info.wave = m_wave;
		info.waveTime = m_waveTime;
		info.bossTime = m_bossTime;
		info.defineID = m_defineID;
		info.spawnedHealth = m_spawnedHealth;
		info.killedHealth = m_killedHealth;
		info.bossHealth = m_bossHealth;
		info.bossDamage = m_bossDamage;
		info.waveStartHealth = m_waveStartHealth;
		return info;
	}

	/**
	 * @brief Unserialize horde data from a POD struct.
	 */
	void unserialize(const hordeInfo_t& info)
	{
		m_state = info.state;
		m_wave = info.wave;
		m_waveTime = info.waveTime;
		m_bossTime = info.bossTime;
		m_defineID = info.defineID;
		m_spawnedHealth = info.spawnedHealth;
		m_killedHealth = info.killedHealth;
		m_bossHealth = info.bossHealth;
		m_bossDamage = info.bossDamage;
		m_waveStartHealth = info.waveStartHealth;
	}

	/**
	 * @brief Clear the boss array and re-check all actors for boss flags.
	 *
	 * @detail Used for loading savegames.
	 */
	void rescanBosses()
	{
		AActor* mo;
		TThinkerIterator<AActor> iterator;

		m_bosses.clear();
		while ((mo = iterator.Next()))
		{
			if (mo->oflags & MFO_BOSSPOOL)
			{
				m_bosses.push_back(mo->ptr());
			}
		}
	}

	/**
	 * @brief Re-count all monsters helper
	 *
	 * @detail Used for loading savegames.
	 *
	 * @param monsterCounts map to update the counts off (m_bossCounts or m_monsterCounts)
	 * @param type Type of monster to increment count of
	 */
	void recountMonstersHelper(mobjCounts_t& monsterCounts, int32_t type)
	{
		if (monsterCounts.count(type))
		{
			monsterCounts[type] += 1;
		}
		else
		{
			monsterCounts[type] = 1;
		}
	}

	/**
	 * @brief Re-count all monsters
	 *
	 * @detail Used for loading savegames.
	 */
	void recountMonsters()
	{
		AActor* mo;
		TThinkerIterator<AActor> iterator;

		m_monsterCounts.clear();
		m_bossCounts.clear();
		while ((mo = iterator.Next()))
		{
			if (mo->health > 0)
			{
				if (mo->oflags & MFO_BOSSPOOL)
				{
					recountMonstersHelper(m_bossCounts, mo->type);
				}
				else
				{
					recountMonstersHelper(m_monsterCounts, mo->type);
				}
			}
		}
	}

	void addSpawnHealth(const int health) { m_spawnedHealth += health; }

	void addKilledHealth(const int health) { m_killedHealth += health; }

	void addBossHealth(const int health) { m_bossHealth += health; }

	void addBossDamage(const int health) { m_bossDamage += health; }

	void addCorpse(AActor* mo) { m_corpses.pushCorpse(mo); }

	void decrementCount(AActor* mo)
	{
		if (mo->oflags & MFO_BOSSPOOL)
		{
			if (m_bossCounts.count(mo->type))
			{
				m_bossCounts[mo->type] -= 1;
			}
		}
		else
		{
			if (m_monsterCounts.count(mo->type))
			{
				m_monsterCounts[mo->type] -= 1;
			}
		}
	}

	size_t getDefineID() const { return m_defineID; }

	int getAliveHealth() const { return m_spawnedHealth - m_killedHealth; }
	void changeState();
	void getNextSpawnTime(int& min, int& max);
	void tick();
} g_HordeDirector;

/**
 * @brief Run functionality that decides if we need to switch states.
 */
void HordeState::changeState()
{
	const hordeDefine_t& define = G_HordeDefine(m_defineID);

	const int goalHealth = define.goalHealth() + m_waveStartHealth;

	switch (m_state)
	{
	case HS_STARTING: {
		if (::level.time > m_waveTime + (3 * TICRATE))
		{
			// We've given enough of a head-start.
			setState(HS_PRESSURE);
		}
		return;
	}
	case HS_PRESSURE: {
		size_t alive = countLivingBosses();
		if (m_killedHealth > goalHealth && (m_bosses.empty() || alive < m_bossRecipe.limit))
		{
			// We reached the goal, spawn the boss.
			setState(HS_WANTBOSS);
		}
		return;
	}
	case HS_RELAX: {
		size_t alive = countLivingBosses();
		if (m_killedHealth > goalHealth && (m_bosses.empty() || alive < m_bossRecipe.limit))
		{
			// We reached the goal, spawn the boss.
			setState(HS_WANTBOSS);
		}
		else
		{
			// There's not enough monsters in the level, add more.
			setState(HS_PRESSURE);
		}
		return;
	}
	case HS_WANTBOSS: {
		if (m_bossRecipe.isValid() && static_cast<int>(m_bosses.size()) >= m_bossRecipe.count)
		{
			// Doesn't matter which state we enter, but we're more likely
			// to be in the relax state after spawning a big hunk of HP.
			setState(HS_PRESSURE);
		}
		return;
	}
	default:
		return;
	}
}

/**
 * @brief Calculate the next spawn time for a monster group.
 *
 * @param min Minimum spawn time, in seconds.
 * @param max Maximum spawn time, in seconds.
 */
void HordeState::getNextSpawnTime(int& min, int& max)
{
	const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());

	const double EMPTY_MIN_SPAWN = g_horde_spawnempty_min;
	const double EMPTY_MAX_SPAWN = g_horde_spawnempty_max;
	const double FULL_MIN_SPAWN = g_horde_spawnfull_min;
	const double FULL_MAX_SPAWN = g_horde_spawnfull_max;

	// Minimum/maximum monster spawn time.
	int alive = getAliveHealth();

	// Slow down the spawn rate if we have bosses.
	if (!m_bosses.empty())
	{
		alive += (define.maxTotalHealth() - alive) / 2;
	}

	double minf = Remap(alive, define.minTotalHealth(), define.maxTotalHealth(),
	                    EMPTY_MIN_SPAWN, EMPTY_MAX_SPAWN);
	double maxf = Remap(alive, define.minTotalHealth(), define.maxTotalHealth(),
	                    FULL_MIN_SPAWN, FULL_MAX_SPAWN);

	// Don't absolutely pound the players in the first minute.
	const int FALLOFF_TIME = m_waveTime + ::HORDE_STARTING_TICS + TICRATE * 60;
	if (::level.time < FALLOFF_TIME)
	{
		const double falloff = Remap(::level.time, m_waveTime, FALLOFF_TIME, 1.0, 0.0);
		const double floormin = Remap(falloff, 0.0, 1.0, 0.0, EMPTY_MIN_SPAWN);
		const double floormax = Remap(falloff, 0.0, 1.0, 0.0, EMPTY_MAX_SPAWN);
		minf = MAX(minf, floormin);
		maxf = MAX(maxf, floormax);
	}

	// Turn into integers.
	min = MAX(int(round(minf)), 1);
	max = MAX(int(round(maxf)), 1);
	max = MAX(max, min);
}

/**
 * @brief Run the state logic itself.
 */
void HordeState::tick()
{
	const hordeDefine_t& define = G_HordeDefine(m_defineID);

	// Run GC on monster corpses.
	m_corpses.tick();

	// Are the bosses taken care of?
	if (m_state != HS_WANTBOSS && m_bossRecipe.isValid())
	{
		size_t alive = countLivingBosses();
		if (!alive && m_bossRecipe.limit <= 0)
		{
			// Start the next wave.
			nextWave();
			return;
		}
	}

	if (::level.time >= m_nextSpawn && getAliveHealth() <= define.maxTotalHealth())
	{
		int min, max;
		getNextSpawnTime(min, max);

		// Randomly select our next spawn time.
		const int offset = P_RandomInt(max - min + 1) + min;
		m_nextSpawn = ::level.time + (offset * TICRATE);

		// Should we spawn a monster?
		switch (m_state)
		{
		case HS_PRESSURE: {
			if (sv_nomonsters)
				break;

			// Pick a recipe for some monsters.
			hordeRecipe_t recipe;
			const bool ok = P_HordeSpawnRecipe(recipe, define, false, m_monsterCounts);
			if (!ok)
			{
				if (recipe.count >= 0)
					PrintFmt(PRINT_WARNING, "{}: No spawn recipe for monster.\n", __FUNCTION__);
				return;
			}

			// Choose a spawn point.
			hordeSpawn_t* spawn = P_HordeSpawnPoint(recipe);
			if (spawn == NULL)
			{
				PrintFmt(PRINT_WARNING, "{}: Can't find a place to spawn {}.\n",
				       __FUNCTION__, ::mobjinfo[recipe.type].name);
				return;
			}

			const int hp = ::mobjinfo[recipe.type].spawnhealth * recipe.count;
			DPrintFmt("Spawning {} {} ({} hp) at a {} spawn\n", recipe.count,
			          ::mobjinfo[recipe.type].name, hp, HordeThingStr(spawn->type));

			AActors mobjs = P_HordeSpawn(*spawn, recipe, m_monsterCounts);
			ActivateMonsters(mobjs);
			break;
		}
		case HS_RELAX:
			break;
		case HS_WANTBOSS: {
			if (sv_nomonsters)
				break;

			// Do we already have bosses spawned?
			if (m_bossRecipe.isValid() && static_cast<int>(m_bosses.size()) >= m_bossRecipe.count)
				break;

			hordeRecipe_t recipe;
			if (!m_bossRecipe.isValid())
			{
				// Pick a recipe for the boss.
				const bool ok = P_HordeSpawnRecipe(recipe, define, true, m_bossCounts);
				if (!ok)
				{
					PrintFmt(PRINT_WARNING, "{}: No spawn recipe for boss monster.\n",
					       __FUNCTION__);
					break;
				}

				// Save for later - in case we need to spawn twice.
				m_bossRecipe = recipe;
			}
			else
			{
				// Adjust the count based on how many bosses we've spawned and current boss limit.
				recipe = m_bossRecipe;
				recipe.count = m_bossRecipe.totalCount - int(m_bosses.size());
				int alive = int(countLivingBosses());
				if (recipe.limit)
				{
					if (recipe.count <= 0)
					{
						m_bossRecipe.limit = 0;
						recipe.count = 0;
					}
					else
						recipe.count = clamp(recipe.limit - alive, 0, recipe.count);
				}
			}

			// Spawn a boss if we don't have one.
			hordeSpawn_t* spawn = P_HordeSpawnPoint(recipe);
			if (spawn == NULL)
			{
				PrintFmt(PRINT_WARNING, "{}: Can't find a place to spawn {}.\n",
				       __FUNCTION__, ::mobjinfo[recipe.type].name);
				break;
			}

			AActors mobjs = P_HordeSpawn(*spawn, recipe, m_bossCounts);
			m_bosses.insert(m_bosses.end(), mobjs.begin(), mobjs.end());
			ActivateMonsters(mobjs);
			break;
		}
		case HS_STARTING:
			break;
		}
	}

	// Always try to spawn an item.
	P_HordeSpawnItem();

	if (::level.time >= m_nextPowerup)
	{
		// Inject horde powerups
		hordeDefine_t def = G_HordeDefine(m_defineID);

		if (G_IsLivesGame())
		{
			// Add extra life/resurrect powerups when
			if (g_horde_extralife.value() > 0.0f)
			{
				hordeDefine_t::powConfig_t config;
				config.chance = g_horde_extralife.value();
				def.addPowerup(MT_EXTRALIFE, config);
			}



			if (g_horde_resurrect.value() > 0.0f && PlayerQuery().notHasLives().execute().count > 0)
			{
				hordeDefine_t::powConfig_t config;
				config.chance = g_horde_resurrect.value();
				def.addPowerup(MT_RESTEAMMATE, config);
			}
		}

		// Always try to spawn a powerup between 30-45 seconds.
		if (!def.powerups.empty())
		{
			const int offset = P_RandomInt(16) + 30;
			m_nextPowerup = ::level.time + (offset * TICRATE);

			const mobjtype_t pw = def.randomPowerup().mobj;
			P_HordeSpawnPowerup(pw);
		}
	}
}

void P_InitHorde()
{
	::g_HordeDirector.reset(false);
}

void P_NextSpawnTime(int& min, int& max)
{
	::g_HordeDirector.getNextSpawnTime(min, max);
}

hordeInfo_t P_HordeInfo()
{
	return ::g_HordeDirector.serialize();
}

void P_SetHordeInfo(const hordeInfo_t& info)
{
	::g_HordeDirector.unserialize(info);
}

void P_AddHealthPool(AActor* mo)
{
	// Already added?
	if (mo->oflags & MFO_HEALTHPOOL)
		return;

	// Counts as a monster?
	if (!(mo->flags & MF_COUNTKILL || mo->type == MT_SKULL))
		return;

	// Mark as part of the health pool for cleanup later
	mo->oflags |= MFO_HEALTHPOOL;

	::g_HordeDirector.addSpawnHealth(::mobjinfo[mo->type].spawnhealth);

	// Bosses also have health added to a separate pool for display purposes.
	if (mo->oflags & MFO_BOSSPOOL)
	{
		::g_HordeDirector.addBossHealth(::mobjinfo[mo->type].spawnhealth);
	}
}

void P_RemoveHealthPool(AActor* mo)
{
	// Not a part of the pool
	if (!(mo->oflags & MFO_HEALTHPOOL))
		return;

	// Unset the flag - we only get one try.
	mo->oflags &= ~MFO_HEALTHPOOL;

	::g_HordeDirector.decrementCount(mo);

	::g_HordeDirector.addKilledHealth(::mobjinfo[mo->type].spawnhealth);
}

void P_AddDamagePool(AActor* mo, const int damage)
{
	// Not a part of the pool
	if (!(mo->oflags & MFO_BOSSPOOL))
		return;

	// Counts as a monster?
	if (!(mo->flags & MF_COUNTKILL || mo->type == MT_SKULL))
		return;

	::g_HordeDirector.addBossDamage(damage);
}

void P_QueueCorpseForDestroy(AActor* mo)
{
	// Only needed for horde.
	if (!G_IsHordeMode())
		return;

	::g_HordeDirector.addCorpse(mo);
}

void P_RunHordeTics()
{
	if (!G_IsHordeMode())
		return;

	// Move this function to tick inside someplace that can be paused.
	if (::paused)
		return;

	if (::level.time == 0)
	{
		::g_HordeDirector.reset(true);
	}

	// Add our spawns if a level reload or reset erased our previous spawns.
	if (!P_HordeHasSpawns())
	{
		P_HordeAddSpawns();
		if (!P_HordeHasSpawns() || !P_HordeHasRequiredMonsterSpawns())
		{
			if (::level.time == 0)
			{
				PrintFmt(
				    PRINT_WARNING,
				    "WARNING: This map is missing Horde Monster, Horde Boss, Horde Supply Cache, "
				    "or Horde Powerup spawns.  At least one of each must be present.\n");
			}

			// This map has no horde things in it - probably inside a
			// non-horde map.
			return;
		}
	}

	// Pause game logic if levelstate doesn't allow it.
	if (!G_CanTickGameplay())
		return;

	// Pause game logic if nobody is in the game.
	PlayerResults targets = PlayerQuery().hasHealth().execute();
	if (targets.count == 0)
		return;

	// We can always change state.
	::g_HordeDirector.changeState();

	// The director is only run once a second.
	if (P_AtInterval(TICRATE))
		::g_HordeDirector.tick();
}

bool P_IsHordeThing(const int type)
{
	return type >= TTYPE_HORDE_SMALLMONSTER && type <= TTYPE_HORDE_SMALLBOSS;
}

const hordeDefine_t::weapons_t& P_HordeWeapons()
{
	if (G_IsHordeMode())
	{
		const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
		return define.weapons;
	}
	else
	{
		static hordeDefine_t::weapons_t weapons;
		if (weapons.empty())
		{
			weapons.push_back(wp_shotgun);
			weapons.push_back(wp_chaingun);
			weapons.push_back(wp_supershotgun);
			weapons.push_back(wp_missile);
			weapons.push_back(wp_plasma);
			weapons.push_back(wp_bfg);
		}
		return weapons;
	}
}

const hordeDefine_t::ammos_t& P_HordeAmmos()
{
	if (G_IsHordeMode())
	{
		const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
		return define.ammos;
	}
	else
	{
		static hordeDefine_t::ammos_t ammos;
		if (ammos.empty())
		{
			// [AM] Shells are first because a typical define leads with the
			//      shotgun and the pistol is not accounted for at all.
			ammos.push_back(am_shell);
			ammos.push_back(am_clip);
			ammos.push_back(am_misl);
			ammos.push_back(am_cell);
		}
		return ammos;
	}
}

/**
 * @brief Serialize horde using FArchive.
 */
void P_SerializeHorde(FArchive& arc)
{
	if (arc.IsStoring())
	{
		hordeInfo_t info = ::g_HordeDirector.serialize();
		const int state = info.state;
		const uint32_t defineID = static_cast<uint32_t>(info.defineID);
		arc << state << info.wave << info.waveTime << info.bossTime << defineID
		    << info.spawnedHealth << info.killedHealth << info.bossHealth
		    << info.bossDamage << info.waveStartHealth;
	}
	else
	{
		hordeInfo_t info;
		int state;
		uint32_t defineID;
		arc >> state >> info.wave >> info.waveTime >> info.bossTime >> defineID >>
		    info.spawnedHealth >> info.killedHealth >> info.bossHealth >>
		    info.bossDamage >> info.waveStartHealth;
		info.state = static_cast<hordeState_e>(state);
		info.defineID = defineID;
		::g_HordeDirector.unserialize(info);
	}
}

/**
 * @brief Do last minute fixups after loading a savegame.
 */
void P_HordePostLoad()
{
	if (!G_IsHordeMode())
		return;

	::g_HordeDirector.rescanBosses();
	::g_HordeDirector.recountMonsters();
}

BEGIN_COMMAND(hordewave)
{
	if (argc < 2)
	{
		PrintFmt("hordewave - Restarts the current wave with a new definition\n"
		       "Usage:\n"
		       "  ] hordewave <DEF NAME>\n"
		       "  Starts the wave named DEF NAME.  The name can be partial.\n");
		return;
	}

	if (!G_IsHordeMode())
	{
		PrintFmt("Can't change the wave define outside of horde mode.\n");
		return;
	}

	if (!::g_HordeDirector.forceWave(argv[1]))
	{
		PrintFmt("Could not find wave define starting with \"{}\"\n", argv[1]);
	}
}
END_COMMAND(hordewave)

BEGIN_COMMAND(hordenextwave)
{
	if (!G_IsHordeMode())
	{
		PrintFmt("Can't advance the wave outside of horde mode.\n");
		return;
	}

	::g_HordeDirector.nextWave();
}
END_COMMAND(hordenextwave)

BEGIN_COMMAND(hordeboss)
{
	if (!G_IsHordeMode())
	{
		PrintFmt("Can't spawn a horde boss outside of horde mode.\n");
		return;
	}

	if (::g_HordeDirector.forceBoss())
	{
		PrintFmt("Spawned the boss.\n");
	}
	else
	{
		PrintFmt("Could not spawn a boss.\n");
	}
}
END_COMMAND(hordeboss)

EXTERN_CVAR(g_horde_mintotalhp)
EXTERN_CVAR(g_horde_maxtotalhp)
EXTERN_CVAR(g_horde_goalhp)

BEGIN_COMMAND(hordeinfo)
{
	if (!G_IsHordeMode())
	{
		PrintFmt("Can't obtain horde info outside of horde mode.\n");
		return;
	}

	float skillScaler = 1.0f;
	if (sv_skill == sk_medium)
		skillScaler = 0.75f;
	else if (sv_skill == sk_baby || sv_skill == sk_easy)
		skillScaler = 0.5f;

	const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());

	PrintFmt("[Define: {}]\n", define.name);
	PrintFmt("Weapons: {}\n", JoinStrings(define.weaponStrings(NULL), " "));
	PrintFmt("Min Group Health: {}\n", define.minGroupHealth);
	PrintFmt("Max Group Health: {} (Difficulty: {})\n", define.maxGroupHealth,
	         define.difficulty(false));
	PrintFmt("Min Total Health: {} = waveMaxGroup:{} * g_horde_mintotalhp:{} * "
	         "skillLevel:{:0.2f}\n",
	         define.minTotalHealth(), define.maxGroupHealth, ::g_horde_mintotalhp.str(),
	         skillScaler);
	PrintFmt("Max Total Health: {} = waveMaxGroup:{} * g_horde_maxtotalhp:{} * "
	         "skillLevel:{:0.2f}\n",
	         define.maxTotalHealth(), define.maxGroupHealth, ::g_horde_maxtotalhp.str(),
	         skillScaler);
	PrintFmt("Goal Health: {} = waveMaxGroup:{} * g_horde_goalhp:{} * skillLevel:{:0.2f}\n",
	         define.goalHealth(), define.maxGroupHealth, ::g_horde_goalhp.str(),
	         skillScaler);

	const char* stateStr = NULL;
	switch (::g_HordeDirector.serialize().state)
	{
	case HS_STARTING:
		stateStr = "Starting";
		break;
	case HS_PRESSURE:
		stateStr = "Pressure";
		break;
	case HS_RELAX:
		stateStr = "Relax";
		break;
	case HS_WANTBOSS:
		stateStr = "WantBoss";
		break;
	}

	int min, max;
	::g_HordeDirector.getNextSpawnTime(min, max);

	PrintFmt("[Wave:{}d]\n", ::g_HordeDirector.serialize().wave);
	PrintFmt("State: {}\n", stateStr);

	if (::g_HordeDirector.getAliveHealth() <= define.maxTotalHealth())
	{
		PrintFmt("Current Spawn Rate: {}-{}sec\n", min, max);
	}
	else
	{
		PrintFmt("Current Spawn Rate: PAUSED (Above Max Health)\n");
	}
	PrintFmt("Empty/Full Spawn Rate: {}-{}sec, {}-{}sec\n", g_horde_spawnempty_min.asInt(),
	         g_horde_spawnempty_max.asInt(), g_horde_spawnfull_min.asInt(),
	         g_horde_spawnfull_max.asInt());
	PrintFmt("Alive Health: {}\n", ::g_HordeDirector.serialize().alive());
	PrintFmt("Killed Health: {}\n", ::g_HordeDirector.serialize().killed());
	PrintFmt("Boss Health: {}\n", ::g_HordeDirector.serialize().bossHealth);
	PrintFmt("Boss Damage: {}\n", ::g_HordeDirector.serialize().bossDamage);
}
END_COMMAND(hordeinfo)
