using System;
using System.Collections.Generic;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Constants
{
///
/// Tier IDs passed to the Global Wave Director. These are deliberately 0-based:
/// 0=T1, 1=T2, and 2=T3. They are not the six IDs used by the threat curve.
///
public enum Defense25DMonsterTierId
{
T1 = 0,
T2 = 1,
T3 = 2,
}
public enum Defense25DMonsterAttackArchetype
{
Contact = 0,
Melee = 1,
Charge = 2,
Projectile = 3,
Area = 4,
}
[Serializable]
public sealed class Defense25DMonsterCombatProfile
{
[SerializeField] private string definitionBasisId;
[SerializeField] private string attackAction = "Attack";
[SerializeField] private Defense25DMonsterAttackArchetype archetype;
[SerializeField] private EnemyAttackShape attackShape;
[SerializeField, Min(0f)] private float bodyRadius = 0.4f;
[SerializeField, Min(0f)] private float moveSpeed = 1.5f;
[SerializeField, Min(0f)] private float attackDamage = 8f;
[SerializeField, Min(0f)] private float warningSeconds = 0.6f;
[SerializeField, Min(0f)] private float activeSeconds = 0.2f;
[SerializeField, Min(0f)] private float recoverySeconds = 0.7f;
[SerializeField, Min(0f)] private float attackRange = 0.9f;
[SerializeField, Min(0f)] private float attackLength = 0.8f;
[SerializeField, Min(0f)] private float attackWidth = 0.6f;
[SerializeField, Min(0f)] private float attackRadius = 0.5f;
[SerializeField, Min(0f)] private float projectileSpeed;
[SerializeField, Min(0f)] private float projectileRadius;
[SerializeField] private bool isFlying;
public string DefinitionBasisId => definitionBasisId;
public string AttackAction => attackAction;
public Defense25DMonsterAttackArchetype Archetype => archetype;
public EnemyAttackShape AttackShape => attackShape;
public float BodyRadius => bodyRadius;
public float MoveSpeed => moveSpeed;
public float AttackDamage => attackDamage;
public float WarningSeconds => warningSeconds;
public float ActiveSeconds => activeSeconds;
public float RecoverySeconds => recoverySeconds;
public float AttackRange => attackRange;
public float AttackLength => attackLength;
public float AttackWidth => attackWidth;
public float AttackRadius => attackRadius;
public float ProjectileSpeed => projectileSpeed;
public float ProjectileRadius => projectileRadius;
public bool IsFlying => isFlying;
public bool TryValidate(out string error)
{
if (string.IsNullOrWhiteSpace(definitionBasisId))
return Fail("CombatProfile.DefinitionBasisId is required.", out error);
if (string.IsNullOrWhiteSpace(attackAction))
return Fail("CombatProfile.AttackAction is required.", out error);
if (!Enum.IsDefined(typeof(Defense25DMonsterAttackArchetype), archetype)
|| !Enum.IsDefined(typeof(EnemyAttackShape), attackShape))
return Fail("CombatProfile has an unsupported attack archetype or shape.", out error);
if (!IsFinite(bodyRadius) || bodyRadius <= 0f
|| !IsFinite(moveSpeed) || moveSpeed <= 0f
|| !IsFinite(attackDamage) || attackDamage <= 0f
|| !IsFinite(warningSeconds) || warningSeconds <= 0f
|| !IsFinite(activeSeconds) || activeSeconds <= 0f
|| !IsFinite(recoverySeconds) || recoverySeconds <= 0f
|| !IsFinite(attackRange) || attackRange <= 0f
|| !IsFinite(attackLength) || attackLength < 0f
|| !IsFinite(attackWidth) || attackWidth < 0f
|| !IsFinite(attackRadius) || attackRadius < 0f
|| !IsFinite(projectileSpeed) || projectileSpeed < 0f
|| !IsFinite(projectileRadius) || projectileRadius < 0f)
return Fail("CombatProfile movement, body, and attack timings must be finite and positive; shape dimensions must be finite and non-negative.", out error);
if (archetype == Defense25DMonsterAttackArchetype.Projectile && projectileSpeed <= 0f)
return Fail("Projectile CombatProfile requires a positive ProjectileSpeed.", out error);
if (archetype == Defense25DMonsterAttackArchetype.Area && attackRadius <= 0f)
return Fail("Area CombatProfile requires a positive AttackRadius.", out error);
if (archetype == Defense25DMonsterAttackArchetype.Charge && attackLength <= 0f)
return Fail("Charge CombatProfile requires a positive AttackLength.", out error);
error = null;
return true;
}
internal static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
private static bool Fail(string message, out string error)
{
error = message;
return false;
}
}
[Serializable]
public sealed class Defense25DMonsterPoolEntry
{
[SerializeField] private EnemyDefinition definition;
[SerializeField] private GameObject visualPrefab;
[SerializeField] private string enemyDefinitionId;
[SerializeField] private string sourceCharacterId;
[SerializeField, Min(0f)] private float weight = 1f;
[SerializeField, Min(0f)] private float threatCost = 1f;
[SerializeField] private Defense25DMonsterCombatProfile combatProfile;
public EnemyDefinition Definition => definition;
public GameObject VisualPrefab => visualPrefab;
public string EnemyDefinitionId => enemyDefinitionId;
public string SourceCharacterId => sourceCharacterId;
public float Weight => weight;
public float ThreatCost => threatCost;
public Defense25DMonsterCombatProfile CombatProfile => combatProfile;
public Defense25DWaveEnemyEntry ToDirectorEntry()
{
return new Defense25DWaveEnemyEntry(enemyDefinitionId, threatCost, weight);
}
}
[Serializable]
public sealed class Defense25DMonsterTierPool
{
[SerializeField] private int tierId;
[SerializeField, Min(0f)] private float fromCombatSeconds;
[SerializeField] private Defense25DMonsterPoolEntry[] entries = Array.Empty();
public int TierId => tierId;
public float FromCombatSeconds => fromCombatSeconds;
public IReadOnlyList Entries => entries ?? Array.Empty();
internal Defense25DMonsterPoolEntry[] EntryArray => entries;
}
public sealed class Defense25DWaveComposition
{
private readonly Defense25DMonsterPoolEntry[] selectedEntries;
private readonly EnemyDefinition[] enemyDefinitions;
private readonly string[] enemyDefinitionIds;
private readonly float[] threatCosts;
public int WaveId { get; }
public int Seed { get; }
public float NominalStart { get; }
public int TierId { get; }
public float ThreatBudget { get; }
public float ComposedThreat { get; }
public bool DiversityConstraintRelaxed { get; }
public IReadOnlyList SelectedEntries => selectedEntries;
public IReadOnlyList EnemyDefinitions => enemyDefinitions;
public IReadOnlyList EnemyDefinitionIds => enemyDefinitionIds;
public IReadOnlyList ThreatCosts => threatCosts;
internal Defense25DWaveComposition(int waveId, int seed, float nominalStart, int tierId, float threatBudget,
float composedThreat, bool diversityConstraintRelaxed, Defense25DMonsterPoolEntry[] selected)
{
WaveId = waveId;
Seed = seed;
NominalStart = nominalStart;
TierId = tierId;
ThreatBudget = threatBudget;
ComposedThreat = composedThreat;
DiversityConstraintRelaxed = diversityConstraintRelaxed;
selectedEntries = (Defense25DMonsterPoolEntry[])selected.Clone();
enemyDefinitions = new EnemyDefinition[selected.Length];
enemyDefinitionIds = new string[selected.Length];
threatCosts = new float[selected.Length];
for (int i = 0; i < selected.Length; i++)
{
enemyDefinitions[i] = selected[i].Definition;
enemyDefinitionIds[i] = selected[i].EnemyDefinitionId;
threatCosts[i] = selected[i].ThreatCost;
}
}
}
[CreateAssetMenu(menuName = "BumpCombat/Defense 2.5D/Monster Pools")]
public sealed class Defense25DMonsterPoolDefinition : ScriptableObject
{
public const float Tier2StartSeconds = 180f;
public const float Tier3StartSeconds = 360f;
private static readonly string[][] AllowedIdsByTier =
{
new[] { "Slime", "Bat", "Skeleton", "Orc", "Hellbat", "Hellhound", "LavaSlime" },
new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredOrc", "BloodMonster_A", "BloodMonster_B", "EyeballMonster", "Ghostfire" },
new[] { "GreatswordSkeleton", "EliteOrc", "OrcRider", "Demon_A", "Demon_B", "Demon_C", "Demon_D", "Demon_E", "Demoness_A", "Demoness_B", "FlameGolem", "Minotaur" },
};
private static readonly string[] ExcludedIds =
{
"Archer", "Knight", "KnightTemplar", "Lancer", "Priest", "Soldier", "Swordsman",
"Wizard", "ArmoredAxeman", "BlackKnight_A", "BlackKnight_B", "BlackKnight_C", "Warlock",
};
private static readonly string[] ExistingDefinitionBasisIds =
{
"Slime", "Bat", "Skeleton", "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "GreatswordSkeleton",
};
[SerializeField] private Defense25DMonsterTierPool[] tiers = Array.Empty();
public IReadOnlyList Tiers => tiers ?? Array.Empty();
/// Converts nominal combat time to the pool's 0-based tier ID.
public static bool TryGetTierForNominalStart(float nominalStart, out int tierId)
{
tierId = -1;
if (!Defense25DMonsterCombatProfile.IsFinite(nominalStart) || nominalStart < 0f)
return false;
tierId = nominalStart < Tier2StartSeconds ? (int)Defense25DMonsterTierId.T1
: nominalStart < Tier3StartSeconds ? (int)Defense25DMonsterTierId.T2
: (int)Defense25DMonsterTierId.T3;
return true;
}
///
/// Returns candidates in the format consumed by Defense25DWaveDirector.
/// Only normalized IDs 0, 1, and 2 are valid; threat-curve IDs are never remapped.
///
public IReadOnlyList GetDirectorEntriesForTier(int tierId)
{
if (tierId < 0 || tierId > 2)
throw new ArgumentOutOfRangeException(nameof(tierId), tierId,
"Monster tier ID must be normalized to 0=T1, 1=T2, or 2=T3.");
Defense25DMonsterTierPool tier = FindTier(tierId);
if (tier == null)
throw new InvalidOperationException($"Monster pool is missing tier {tierId}.");
Defense25DMonsterPoolEntry[] entries = tier.EntryArray;
if (entries == null || entries.Length == 0)
throw new InvalidOperationException($"Monster pool tier {tierId} has no entries.");
Defense25DWaveEnemyEntry[] result = new Defense25DWaveEnemyEntry[entries.Length];
for (int i = 0; i < entries.Length; i++)
{
Defense25DMonsterPoolEntry entry = entries[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.EnemyDefinitionId)
|| entry.ThreatCost <= 0f || entry.Weight <= 0f)
throw new InvalidOperationException($"Monster pool tier {tierId} contains an invalid entry at index {i}.");
result[i] = entry.ToDirectorEntry();
}
return result;
}
///
/// Builds a deterministic, weighted-with-replacement composition from the tier selected
/// by nominalStart. The Director consumes this result when it distributes entries to
/// Nodes and schedules their spawn windows; actualStart is intentionally not an input.
///
public Defense25DWaveComposition BuildWaveComposition(int waveId, float nominalStart,
float threatBudget, int waveSeed)
{
if (waveId < 0)
throw new ArgumentOutOfRangeException(nameof(waveId), "Wave ID must be non-negative.");
if (!TryGetTierForNominalStart(nominalStart, out int tierId))
throw new ArgumentOutOfRangeException(nameof(nominalStart), "Nominal start must be finite and non-negative.");
if (!Defense25DMonsterCombatProfile.IsFinite(threatBudget) || threatBudget <= 0f)
throw new ArgumentOutOfRangeException(nameof(threatBudget), "Threat budget must be finite and positive.");
if (!TryValidate(out string validationError))
throw new InvalidOperationException($"Monster pool is invalid: {validationError}");
Defense25DMonsterPoolEntry[] pool = FindTier(tierId).EntryArray;
DeterministicRandom random = new DeterministicRandom(MixSeed(waveSeed, waveId));
List composition = new List();
Dictionary threatBySpecies = new Dictionary(StringComparer.Ordinal);
double spent = 0d;
bool diversityRelaxed = false;
while (spent < threatBudget)
{
List eligible = new List(pool.Length);
foreach (Defense25DMonsterPoolEntry entry in pool)
{
threatBySpecies.TryGetValue(entry.EnemyDefinitionId, out double current);
if (current + entry.ThreatCost <= threatBudget * 0.60d + 0.0001d)
eligible.Add(entry);
}
if (eligible.Count == 0)
{
// Relax once a constrained draw has no candidates. This guarantees progress
// and records that the 60% target could not be maintained for this wave.
eligible.AddRange(pool);
diversityRelaxed = true;
}
Defense25DMonsterPoolEntry selected = SelectWeighted(eligible, ref random);
composition.Add(selected);
spent += selected.ThreatCost;
threatBySpecies.TryGetValue(selected.EnemyDefinitionId, out double previousThreat);
threatBySpecies[selected.EnemyDefinitionId] = previousThreat + selected.ThreatCost;
if (double.IsNaN(spent) || double.IsInfinity(spent))
throw new InvalidOperationException("Wave composition threat total overflowed.");
}
return new Defense25DWaveComposition(waveId, waveSeed, nominalStart, tierId, threatBudget,
(float)spent, diversityRelaxed, composition.ToArray());
}
public bool TryGetEntry(string enemyDefinitionId, out Defense25DMonsterPoolEntry entry)
{
if (!string.IsNullOrWhiteSpace(enemyDefinitionId) && tiers != null)
{
foreach (Defense25DMonsterTierPool tier in tiers)
{
if (tier?.EntryArray == null) continue;
foreach (Defense25DMonsterPoolEntry candidate in tier.EntryArray)
{
if (candidate != null && string.Equals(candidate.EnemyDefinitionId, enemyDefinitionId, StringComparison.Ordinal))
{
entry = candidate;
return true;
}
}
}
}
entry = null;
return false;
}
public bool TryValidate(out string error)
{
if (tiers == null || tiers.Length != 3)
return Fail("Monster pool must define exactly three tiers (IDs 0, 1, 2).", out error);
Dictionary seen = new Dictionary(StringComparer.Ordinal);
HashSet seenSources = new HashSet(StringComparer.Ordinal);
HashSet seenDefinitions = new HashSet();
HashSet seenVisualPrefabs = new HashSet();
HashSet seenProfiles = new HashSet();
for (int tierIndex = 0; tierIndex < tiers.Length; tierIndex++)
{
Defense25DMonsterTierPool tier = tiers[tierIndex];
float expectedStart = tierIndex == 0 ? 0f : tierIndex == 1 ? Tier2StartSeconds : Tier3StartSeconds;
if (tier == null || tier.TierId != tierIndex || !Defense25DMonsterCombatProfile.IsFinite(tier.FromCombatSeconds)
|| tier.FromCombatSeconds != expectedStart)
return Fail($"Tier at index {tierIndex} must use ID {tierIndex} and start at {expectedStart} seconds.", out error);
Defense25DMonsterPoolEntry[] entries = tier.EntryArray;
if (entries == null || entries.Length == 0)
return Fail($"Tier {tierIndex} must not be empty.", out error);
foreach (Defense25DMonsterPoolEntry entry in entries)
{
if (entry == null)
return Fail($"Tier {tierIndex} contains a null entry.", out error);
if (entry.Definition == null)
return Fail($"Tier {tierIndex} entry {entry.EnemyDefinitionId} has no EnemyDefinition reference.", out error);
if (!seenDefinitions.Add(entry.Definition))
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' EnemyDefinition asset.", out error);
if (entry.VisualPrefab == null)
return Fail($"Species {entry.EnemyDefinitionId} has no visual prefab reference.", out error);
if (!seenVisualPrefabs.Add(entry.VisualPrefab))
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' visual prefab.", out error);
if (string.IsNullOrWhiteSpace(entry.EnemyDefinitionId))
return Fail($"Tier {tierIndex} contains an entry without EnemyDefinitionId.", out error);
if (IsExcluded(entry.EnemyDefinitionId) || IsExcluded(entry.SourceCharacterId))
return Fail($"Excluded human/final-boss species cannot enter the ordinary pool: {entry.EnemyDefinitionId}.", out error);
if (!Contains(AllowedIdsByTier[tierIndex], entry.EnemyDefinitionId))
return Fail($"Species {entry.EnemyDefinitionId} is not explicitly allowed in tier {tierIndex}.", out error);
if (!string.Equals(ExpectedSourceId(entry.EnemyDefinitionId), entry.SourceCharacterId, StringComparison.Ordinal))
return Fail($"Species {entry.EnemyDefinitionId} has mismatched SourceCharacterId '{entry.SourceCharacterId}'.", out error);
if (!IsFinitePositive(entry.Weight) || !IsFinitePositive(entry.ThreatCost))
return Fail($"Species {entry.EnemyDefinitionId} requires finite positive Weight and ThreatCost.", out error);
if (!IsThreatCostInTierRange(tierIndex, entry.ThreatCost))
return Fail($"Species {entry.EnemyDefinitionId} ThreatCost is outside the initial tier range.", out error);
if (!seen.TryAdd(entry.EnemyDefinitionId, tierIndex))
return Fail($"Species {entry.EnemyDefinitionId} appears more than once.", out error);
if (!seenSources.Add(entry.SourceCharacterId))
return Fail($"Source character {entry.SourceCharacterId} appears more than once.", out error);
if (entry.CombatProfile == null)
return Fail($"Species {entry.EnemyDefinitionId} has no CombatProfile.", out error);
if (!seenProfiles.Add(entry.CombatProfile))
return Fail($"Species {entry.EnemyDefinitionId} reuses another species' CombatProfile.", out error);
if (!entry.CombatProfile.TryValidate(out string profileError))
return Fail($"Species {entry.EnemyDefinitionId} has invalid CombatProfile: {profileError}", out error);
if (!Contains(ExistingDefinitionBasisIds, entry.CombatProfile.DefinitionBasisId))
return Fail($"Species {entry.EnemyDefinitionId} must name one of the existing roster definitions as its profile basis.", out error);
}
}
for (int tierIndex = 0; tierIndex < AllowedIdsByTier.Length; tierIndex++)
{
foreach (string allowedId in AllowedIdsByTier[tierIndex])
{
if (!seen.TryGetValue(allowedId, out int actualTier) || actualTier != tierIndex)
return Fail($"Allowed species {allowedId} is missing from tier {tierIndex}.", out error);
}
}
error = null;
return true;
}
private Defense25DMonsterTierPool FindTier(int tierId)
{
if (tiers == null) return null;
foreach (Defense25DMonsterTierPool tier in tiers)
if (tier != null && tier.TierId == tierId) return tier;
return null;
}
private static bool IsThreatCostInTierRange(int tierId, float cost)
{
return tierId switch
{
0 => cost >= 0.8f && cost <= 1.3f,
1 => cost >= 1.5f && cost <= 2.3f,
2 => cost >= 2.4f && cost <= 4f,
_ => false,
};
}
private static Defense25DMonsterPoolEntry SelectWeighted(
IReadOnlyList candidates, ref DeterministicRandom random)
{
double totalWeight = 0d;
for (int i = 0; i < candidates.Count; i++) totalWeight += candidates[i].Weight;
double roll = random.NextDouble() * totalWeight;
for (int i = 0; i < candidates.Count; i++)
{
roll -= candidates[i].Weight;
if (roll < 0d) return candidates[i];
}
return candidates[candidates.Count - 1];
}
private static uint MixSeed(int waveSeed, int waveId)
{
uint value = unchecked((uint)waveSeed) ^ (unchecked((uint)waveId) * 0x9E3779B9u) ^ 0xA341316Cu;
return value == 0u ? 0x6D2B79F5u : value;
}
private struct DeterministicRandom
{
private uint state;
public DeterministicRandom(uint seed) => state = seed == 0u ? 0x6D2B79F5u : seed;
public double NextDouble()
{
// xorshift32 is explicit here so seeded composition does not depend on
// System.Random's implementation version or unrelated global random calls.
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return (state >> 8) * (1.0 / 16777216.0);
}
}
private static string ExpectedSourceId(string enemyDefinitionId)
{
return enemyDefinitionId switch
{
"LavaSlime" => "Lava Slime",
"ArmoredSkeleton" => "Armored Skeleton",
"SkeletonArcher" => "Skeleton Archer",
"ArmoredOrc" => "Armored Orc",
"BloodMonster_A" => "Blood Monster_A",
"BloodMonster_B" => "Blood Monster_B",
"EyeballMonster" => "Eyeball Monster",
"GreatswordSkeleton" => "Greatsword Skeleton",
"EliteOrc" => "Elite Orc",
"OrcRider" => "Orc rider",
"FlameGolem" => "Flame Golem",
"Minotaur" => "Minotaur",
_ => enemyDefinitionId,
};
}
private static bool IsExcluded(string id)
{
return Contains(ExcludedIds, id) || string.Equals(id, "Necromancer", StringComparison.Ordinal);
}
private static bool Contains(string[] values, string candidate)
{
if (string.IsNullOrEmpty(candidate)) return false;
foreach (string value in values)
if (string.Equals(value, candidate, StringComparison.Ordinal)) return true;
return false;
}
private static bool IsFinitePositive(float value) => Defense25DMonsterCombatProfile.IsFinite(value) && value > 0f;
private static bool Fail(string message, out string error)
{
error = message;
return false;
}
}
}