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; } } }