using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Card = ProjectI.Enemies.EnemyDirectorDefinition.CardSettings; using Profile = ProjectI.Enemies.EnemyDirectorDefinition.ProfileSettings; using DirectorSettings = ProjectI.Enemies.EnemyDirectorDefinition.Settings; namespace ProjectI.Enemies { public static class EnemyGroupSelection { // Exhaustion returns no choice; it never changes modes or truncates a group. public const int MaximumSearchNodes = 65536; public readonly struct Context { public readonly string ProfileId; public readonly int Stage, Participants, FreeSlots, Keepers, Lancers; public readonly double ActiveMinutes, Difficulty, AvailableCredit, SpendingLimit; public readonly ulong RunId, StageId; public readonly uint ProfileGeneration; public Context(string profileId, int stage, double activeMinutes, double difficulty, int participants, double availableCredit, double spendingLimit, int freeSlots, int keepers = 0, int lancers = 0, ulong runId = 1, ulong stageId = 1, uint profileGeneration = 1) { ProfileId = profileId; Stage = stage; ActiveMinutes = activeMinutes; Difficulty = difficulty; Participants = participants; AvailableCredit = availableCredit; SpendingLimit = spendingLimit; FreeSlots = freeSlots; Keepers = keepers; Lancers = lancers; RunId = runId; StageId = stageId; ProfileGeneration = profileGeneration; } internal bool Valid => Stage >= 1 && Participants >= 1 && Participants <= 4 && FreeSlots >= 0 && FreeSlots <= 16 && Keepers >= 0 && Keepers <= 2 && Lancers >= 0 && Lancers <= 2 && Finite(ActiveMinutes) && ActiveMinutes >= 0 && Finite(Difficulty) && Difficulty > 0 && Finite(AvailableCredit) && AvailableCredit >= 0 && Finite(SpendingLimit) && SpendingLimit >= 0 && RunId != 0 && StageId != 0 && ProfileGeneration != 0; } public sealed class Result { public ReadOnlyCollection Members { get; } public EnemySelectionMode Mode { get; } public EnemyFaction Faction { get; } public double TotalCost { get; } public uint RngState { get; } public string TemplateId { get; } public string SingleCardId { get; } internal Result(IEnumerable members, EnemySelectionMode mode, EnemyFaction faction, uint rng, string templateId = null, string singleCardId = null) { Members = Array.AsReadOnly(members.ToArray()); Mode = mode; Faction = faction; RngState = rng; TotalCost = Members.Sum(x => x.Cost); TemplateId = templateId; SingleCardId = singleCardId; } } public sealed class SavingIntent { private readonly DirectorSettings settings; public Result Choice { get; } public double CreatedAt { get; } public double ExpiresAt { get; } public double IncomePerSecond { get; } public ulong RunId { get; } public ulong StageId { get; } public uint ProfileGeneration { get; } public string ProfileId { get; } internal SavingIntent(DirectorSettings settings, Context context, Result choice, double now, double expires, double income) { this.settings = settings; Choice = choice; CreatedAt = now; ExpiresAt = expires; IncomePerSecond = income; RunId = context.RunId; StageId = context.StageId; ProfileGeneration = context.ProfileGeneration; ProfileId = context.ProfileId; } public bool IsValid(DirectorSettings currentSettings, Context context, double now) { if (!ReferenceEquals(settings, currentSettings) || !context.Valid || !Finite(now) || now < CreatedAt || now > ExpiresAt || context.RunId != RunId || context.StageId != StageId || context.ProfileGeneration != ProfileGeneration || context.ProfileId != ProfileId) return false; var profile = settings.Profiles.First(x => x.Id == ProfileId); if (Choice.TotalCost > profile.BankCeiling || !Choice.Members.All(x => Eligible(x.Eligibility, context))) return false; if (Choice.Mode == EnemySelectionMode.SingleType && !Eligible(SingleBase(profile, Choice).Eligibility, context)) return false; return Choice.TemplateId == null || Eligible(profile.Templates.First(x => x.Id == Choice.TemplateId).Eligibility, context); } } public static bool TryChoose(DirectorSettings settings, Context context, uint seed, out Result result) { result = null; return context.SpendingLimit <= context.AvailableCredit && Choose(settings, context, seed, -1, out result); } public static bool TryCreateSavingIntent(DirectorSettings settings, Context context, uint seed, double now, out SavingIntent intent) { intent = null; if (!TryProfile(settings, context, out var profile) || !Finite(now) || now < 0 || profile.SavingChance <= 0) return false; double expires = now + profile.SavingSeconds; if (!Finite(expires) || expires <= now) return false; double income = EnemyDirectorRules.Income(profile.BaseIncome, context.Stage, context.ActiveMinutes, context.Difficulty, context.Participants); double reachable = EnemyDirectorRules.Accrue(context.AvailableCredit, profile.SavingSeconds, income, profile.BankCeiling); var future = new Context(context.ProfileId, context.Stage, context.ActiveMinutes, context.Difficulty, context.Participants, context.AvailableCredit, reachable, context.FreeSlots, context.Keepers, context.Lancers, context.RunId, context.StageId, context.ProfileGeneration); if (!Choose(settings, future, seed, context.AvailableCredit, out var choice)) return false; intent = new SavingIntent(settings, context, choice, now, expires, income); return true; } private static bool Choose(DirectorSettings settings, Context context, uint seed, double costAbove, out Result result) { result = null; if (!TryProfile(settings, context, out var profile) || context.FreeSlots == 0 || context.SpendingLimit > profile.BankCeiling) return false; uint rng = seed; int mode = Draw(new[] { Log(profile.SingleWeight), Log(profile.TemplateWeight), Log(profile.ProceduralWeight) }, ref rng); return mode >= 0 && TryMode(settings, profile, context, (EnemySelectionMode)mode, costAbove, false, ref rng, out result); } private static bool TryProfile(DirectorSettings settings, Context context, out Profile profile) { profile = null; if (settings == null || !context.Valid) return false; profile = settings.Profiles.FirstOrDefault(x => x.Id == context.ProfileId); return profile != null && context.AvailableCredit <= profile.BankCeiling; } internal static bool HasAffordableModes(DirectorSettings settings, Profile profile) { var context = new Context(profile.Id, 1, 0, 1, 1, profile.BankCeiling, profile.BankCeiling, profile.AdmissionLimit); uint rng = 1; foreach (var mode in new[] { EnemySelectionMode.SingleType, EnemySelectionMode.Template, EnemySelectionMode.Procedural }) { double weight = mode == EnemySelectionMode.SingleType ? profile.SingleWeight : mode == EnemySelectionMode.Template ? profile.TemplateWeight : profile.ProceduralWeight; if (weight > 0 && !TryMode(settings, profile, context, mode, -1, true, ref rng, out _)) return false; } return true; } private static bool TryMode(DirectorSettings settings, Profile profile, Context context, EnemySelectionMode mode, double costAbove, bool authoring, ref uint rng, out Result result) { result = null; int slots = Math.Min(context.FreeSlots, profile.AdmissionLimit); if (mode == EnemySelectionMode.SingleType) { var choices = new List(); var weights = new List(); foreach (var card in profile.Cards) { var sizes = new List(); for (int count = profile.SingleMinimum; count <= profile.SingleMaximum && count <= slots; count++) { var group = Enumerable.Repeat(card, count).ToArray(); // Ordinary purchases always retain an affordable normal fallback. Saving // conditions the final outcome on costAbove, including upgraded-only targets. if (!Fits(group, context, authoring, -1)) continue; bool normalAllowed = Fits(group, context, authoring, costAbove); var upgrades = card.Tier == EnemyDirectorTier.Normal && card.SinglePackEliteChance > 0 ? profile.Cards.Where(candidate => IsEliteUpgrade(card, candidate) && FitsUpgrade(settings, group, candidate, context, authoring, costAbove)).ToArray() : Array.Empty(); if (normalAllowed || upgrades.Length > 0) sizes.Add(new SingleChoice(group, upgrades, normalAllowed)); } foreach (var choice in sizes) { choices.Add(choice); weights.Add(Math.Log(card.Weight) - Math.Log(sizes.Count)); } } int index = Draw(weights, ref rng); if (index < 0) return false; var selected = choices[index]; var members = selected.Normal; var basis = members[0]; if (selected.Upgrades.Length > 0 && (!selected.NormalAllowed || Next(ref rng) / 4294967296d < basis.SinglePackEliteChance)) { int variant = Draw(selected.Upgrades.Select(c => Math.Log(c.Weight)).ToArray(), ref rng); var positions = Enumerable.Range(0, members.Length).Where(slot => { var actual = (Card[])members.Clone(); actual[slot] = selected.Upgrades[variant]; return Fits(actual, context, authoring, costAbove); }).ToArray(); members = (Card[])members.Clone(); members[positions[(int)(Next(ref rng) % (uint)positions.Length)]] = selected.Upgrades[variant]; } result = new Result(members, mode, EnemyFaction.HollowKnights, rng, singleCardId: basis.Id); return true; } if (mode == EnemySelectionMode.Template) { var choices = profile.Templates.Where(x => x.Members.Count <= slots && (authoring || Eligible(x.Eligibility, context)) && Fits(x.Members, context, authoring, costAbove) && (!authoring || CommonEligibility(x.Members.Select(m => m.Eligibility).Append(x.Eligibility)))).ToArray(); int index = Draw(choices.Select(x => Math.Log(x.Weight)).ToArray(), ref rng); if (index < 0) return false; var chosen = choices[index]; result = new Result(chosen.Members, mode, chosen.Faction, rng, chosen.Id); return true; } var counts = Enumerable.Range(profile.ProceduralMinimum, profile.ProceduralMaximum - profile.ProceduralMinimum + 1).Where(x => x <= slots).ToList(); int nodes = 0; while (counts.Count > 0 && nodes < MaximumSearchNodes) { int at = (int)(Next(ref rng) % (uint)counts.Count); int count = counts[at]; counts.RemoveAt(at); var group = new List(count); if (Build(settings, profile, context, authoring, costAbove, count, group, 0, ref nodes, ref rng)) { result = new Result(group, mode, EnemyFaction.HollowKnights, rng); return true; } } return false; } private sealed class SingleChoice { public readonly Card[] Normal, Upgrades; public readonly bool NormalAllowed; public SingleChoice(Card[] normal, Card[] upgrades, bool normalAllowed) { Normal = normal; Upgrades = upgrades; NormalAllowed = normalAllowed; } } internal static Card SingleBase(Profile profile, Result choice) => profile.Cards.FirstOrDefault(c => c.Id == choice.SingleCardId); internal static bool IsEliteUpgrade(Card basis, Card candidate) => basis.Tier == EnemyDirectorTier.Normal && candidate.Tier == EnemyDirectorTier.Elite && (basis.TypeId == candidate.TypeId || basis.Archetype == EnemyArchetype.Standard && basis.Role == EnemyRole.Ranged && candidate.Archetype == EnemyArchetype.Bombardier && candidate.Role == EnemyRole.Ranged); internal static bool SingleTypeMatches(DirectorSettings settings, Profile profile, Result choice) { var basis = SingleBase(profile, choice); if (basis == null || choice.TemplateId != null || choice.Faction != EnemyFaction.HollowKnights) return false; var replacements = choice.Members.Where(c => !ReferenceEquals(c, basis)).ToArray(); return replacements.Length == 0 || replacements.Length == 1 && basis.SinglePackEliteChance > 0 && IsEliteUpgrade(basis, replacements[0]) && Compatible(settings, choice.Members); } private static bool FitsUpgrade(DirectorSettings settings, Card[] normal, Card candidate, Context context, bool authoring, double costAbove) { var upgraded = (Card[])normal.Clone(); upgraded[0] = candidate; return Fits(upgraded, context, authoring, costAbove) && Compatible(settings, upgraded) && (!authoring || CommonEligibility(upgraded.Append(normal[0]).Select(c => c.Eligibility))); } private static bool Compatible(DirectorSettings settings, IReadOnlyList group) => !group.SelectMany((a, i) => group.Skip(i + 1).Select(b => (a, b))) .Any(pair => settings.HardExclusions.Any(r => EnemyDirectorDefinition.Pair(r.firstType, r.secondType) == EnemyDirectorDefinition.Pair(pair.a.TypeId, pair.b.TypeId))); private static bool Build(DirectorSettings settings, Profile profile, Context context, bool authoring, double costAbove, int count, List group, double cost, ref int nodes, ref uint rng) { if (++nodes > MaximumSearchNodes) return false; if (group.Count == count) return cost > costAbove && Fits(group, context, authoring, costAbove); var candidates = new List(); var weights = new List(); foreach (var card in profile.Cards) { if (card.Cost > context.SpendingLimit - cost || (!authoring && !Eligible(card.Eligibility, context)) || group.Any(x => settings.HardExclusions.Any(r => EnemyDirectorDefinition.Pair(r.firstType, r.secondType) == EnemyDirectorDefinition.Pair(x.TypeId, card.TypeId)))) continue; group.Add(card); bool fits = SpecialistCounts(group, context) && (!authoring || CommonEligibility(group.Select(x => x.Eligibility))); group.RemoveAt(group.Count - 1); if (!fits) continue; candidates.Add(card); weights.Add(ProceduralLogWeight(settings, card, group)); } if (candidates.Count == 0) return false; int remaining = count - group.Count; double largest = candidates.Max(x => x.Cost), smallest = candidates.Min(x => x.Cost); if (cost + remaining * largest <= costAbove || remaining * smallest > context.SpendingLimit - cost) return false; while (candidates.Count > 0 && nodes < MaximumSearchNodes) { int index = Draw(weights, ref rng); if (index < 0) return false; var card = candidates[index]; candidates.RemoveAt(index); weights.RemoveAt(index); group.Add(card); if (Build(settings, profile, context, authoring, costAbove, count, group, cost + card.Cost, ref nodes, ref rng)) return true; group.RemoveAt(group.Count - 1); } return false; } private static bool Fits(IReadOnlyCollection group, Context context, bool authoring, double costAbove) { double total = group.Sum(x => x.Cost); return Finite(total) && total > costAbove && total <= context.SpendingLimit && group.Count <= context.FreeSlots && SpecialistCounts(group, context) && (authoring ? CommonEligibility(group.Select(x => x.Eligibility)) : group.All(x => Eligible(x.Eligibility, context))); } private static bool SpecialistCounts(IEnumerable group, Context context) => context.Keepers + group.Count(x => x.Archetype == EnemyArchetype.TetherKeeper) <= 2 && context.Lancers + group.Count(x => x.Archetype == EnemyArchetype.Lancer) <= 2; private static bool Eligible(EnemyDirectorDefinition.Eligibility gate, Context context) => gate.Allows(context.Stage, context.ActiveMinutes, context.Difficulty, context.Participants); private static bool CommonEligibility(IEnumerable source) { var gates = source.ToArray(); return Intersects(gates.Select(x => (double)x.minimumStage), gates.Select(x => (double)x.maximumStage), 1, int.MaxValue) && Intersects(gates.Select(x => x.minimumMinutes), gates.Select(x => x.maximumMinutes), 0, double.MaxValue) && Intersects(gates.Select(x => x.minimumDifficulty), gates.Select(x => x.maximumDifficulty), double.Epsilon, double.MaxValue) && Intersects(gates.Select(x => (double)x.minimumParticipants), gates.Select(x => (double)x.maximumParticipants), 1, 4); } private static bool Intersects(IEnumerable mins, IEnumerable maxes, double floor, double ceiling) => Math.Max(floor, mins.DefaultIfEmpty(floor).Max()) <= Math.Min(ceiling, maxes.Where(x => x > 0).DefaultIfEmpty(ceiling).Min()); public static double ProceduralWeight(DirectorSettings settings, Card card, IReadOnlyList members) => Math.Exp(Math.Min(Math.Log(double.MaxValue), ProceduralLogWeight(settings, card, members))); private static double ProceduralLogWeight(DirectorSettings settings, Card card, IReadOnlyList members) { double weight = Math.Log(card.Weight); var triggers = new HashSet(members.Select(x => x.TypeId), StringComparer.Ordinal); foreach (var rule in settings.SoftModifiers) if (rule.candidateType == card.TypeId && triggers.Contains(rule.triggerType)) weight += Math.Log(rule.multiplier); return weight; } private static int Draw(IReadOnlyList logs, ref uint rng) { if (logs.Count == 0) return -1; double maximum = logs.Max(); if (double.IsNegativeInfinity(maximum)) return -1; double sum = 0; foreach (double weight in logs) sum += Math.Exp(weight - maximum); double sample = (Next(ref rng) / 4294967296d) * sum; for (int i = 0; i < logs.Count; i++) { sample -= Math.Exp(logs[i] - maximum); if (sample < 0) return i; } return logs.Count - 1; } private static double Log(double weight) => weight > 0 ? Math.Log(weight) : double.NegativeInfinity; private static uint Next(ref uint state) { if (state == 0) state = 0x6D2B79F5; state ^= state << 13; state ^= state >> 17; state ^= state << 5; return state; } private static bool Finite(double value) => EnemyDirectorRules.Finite(value); } }