using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using MimirsWell.Core.Domain; using MimirsWell.Core.Indexing; using MimirsWell.Core.Planning; using MimirsWell.Core.Resolution; using MimirsWell.Core.Services; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: AssemblyCompany("MimirsWell.Core")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyDescription("Pure domain, indexing, interpretation, and query logic for Mimir's Well.")] [assembly: AssemblyFileVersion("0.5.4.0")] [assembly: AssemblyInformationalVersion("0.5.4+e9ead6b8be5d9ac71509b88d6e713cfa6d095cc3")] [assembly: AssemblyProduct("MimirsWell.Core")] [assembly: AssemblyTitle("MimirsWell.Core")] [assembly: AssemblyVersion("0.5.4.0")] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } } namespace MimirsWell.Core.Services { internal enum ComparisonMetricId { ListedDamage, Blunt, Slash, Pierce, Fire, Frost, Lightning, Poison, Spirit, PrimaryAttackStamina, SecondaryAttackStamina, Durability, Block, ParryBonus, Armor, FoodHealth, FoodStamina, FoodEitr, FoodDuration, FoodRegeneration, MovementModifier, Weight } internal sealed class ComparisonMetricDefinition { public ComparisonMetricId Id { get; } public string Name { get; } public bool HigherRanksFirst { get; } public string Suffix { get; } public string Caveat { get; } public string DirectionLabel { get { if (!HigherRanksFirst) { return "lower ranks first"; } return "higher ranks first"; } } public ComparisonMetricDefinition(ComparisonMetricId id, string name, bool higherRanksFirst, string suffix, string caveat) { Id = id; Name = name; HigherRanksFirst = higherRanksFirst; Suffix = suffix; Caveat = caveat; } } internal static class ComparisonMetricCatalog { private const string DamageCaveat = "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."; private const string StaminaCaveat = "Not considered: damage per attack, attack timing or chains, regeneration, skill scaling, or real DPS."; private const string DefenseCaveat = "Not considered: resistances, stagger behavior, enemy attack mix, player skill, or effective survivability."; private const string FoodCaveat = "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."; private const string SingleFieldCaveat = "This is a single loaded field, not an overall item score."; private static readonly IReadOnlyDictionary Aliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["damage"] = ComparisonMetricId.ListedDamage, ["listed damage"] = ComparisonMetricId.ListedDamage, ["total damage"] = ComparisonMetricId.ListedDamage, ["attack damage"] = ComparisonMetricId.ListedDamage, ["listed combat damage"] = ComparisonMetricId.ListedDamage, ["blunt"] = ComparisonMetricId.Blunt, ["blunt damage"] = ComparisonMetricId.Blunt, ["slash"] = ComparisonMetricId.Slash, ["slash damage"] = ComparisonMetricId.Slash, ["pierce"] = ComparisonMetricId.Pierce, ["pierce damage"] = ComparisonMetricId.Pierce, ["fire"] = ComparisonMetricId.Fire, ["fire damage"] = ComparisonMetricId.Fire, ["frost"] = ComparisonMetricId.Frost, ["frost damage"] = ComparisonMetricId.Frost, ["lightning"] = ComparisonMetricId.Lightning, ["lightning damage"] = ComparisonMetricId.Lightning, ["poison"] = ComparisonMetricId.Poison, ["poison damage"] = ComparisonMetricId.Poison, ["spirit"] = ComparisonMetricId.Spirit, ["spirit damage"] = ComparisonMetricId.Spirit, ["primary stamina"] = ComparisonMetricId.PrimaryAttackStamina, ["primary attack stamina"] = ComparisonMetricId.PrimaryAttackStamina, ["attack stamina"] = ComparisonMetricId.PrimaryAttackStamina, ["stamina cost"] = ComparisonMetricId.PrimaryAttackStamina, ["secondary stamina"] = ComparisonMetricId.SecondaryAttackStamina, ["secondary attack stamina"] = ComparisonMetricId.SecondaryAttackStamina, ["durability"] = ComparisonMetricId.Durability, ["block"] = ComparisonMetricId.Block, ["block power"] = ComparisonMetricId.Block, ["parry"] = ComparisonMetricId.ParryBonus, ["parry bonus"] = ComparisonMetricId.ParryBonus, ["armor"] = ComparisonMetricId.Armor, ["armour"] = ComparisonMetricId.Armor, ["health"] = ComparisonMetricId.FoodHealth, ["food health"] = ComparisonMetricId.FoodHealth, ["eitr"] = ComparisonMetricId.FoodEitr, ["food eitr"] = ComparisonMetricId.FoodEitr, ["duration"] = ComparisonMetricId.FoodDuration, ["food duration"] = ComparisonMetricId.FoodDuration, ["regeneration"] = ComparisonMetricId.FoodRegeneration, ["regen"] = ComparisonMetricId.FoodRegeneration, ["food regeneration"] = ComparisonMetricId.FoodRegeneration, ["movement"] = ComparisonMetricId.MovementModifier, ["movement modifier"] = ComparisonMetricId.MovementModifier, ["movement penalty"] = ComparisonMetricId.MovementModifier, ["weight"] = ComparisonMetricId.Weight }; public static string SupportedSummary => "damage, a listed combat-damage type, primary/secondary stamina, durability, block, parry, armor, food health/stamina/eitr/duration/regeneration, movement, or weight"; public static bool TryResolve(string metricText, WikiItem left, WikiItem right, out ComparisonMetricDefinition? metric, out string diagnostic) { string text = TextNormalizer.Normalize(metricText); if (text == "stamina") { if (left.Kind == WikiItemKind.Food && right.Kind == WikiItemKind.Food) { return Resolved(ComparisonMetricId.FoodStamina, out metric, out diagnostic); } if (left.Kind == WikiItemKind.Weapon && right.Kind == WikiItemKind.Weapon) { return Resolved(ComparisonMetricId.PrimaryAttackStamina, out metric, out diagnostic); } metric = null; diagnostic = "'stamina' is contextual: compare two foods for food stamina or two weapons for primary attack stamina."; return false; } if (!Aliases.TryGetValue(text, out var value)) { metric = null; diagnostic = "Unsupported comparison metric '" + metricText + "'. Supported metrics: " + SupportedSummary + ". DPS and composite best scores are not supported."; return false; } return Resolved(value, out metric, out diagnostic); } public static bool TryValue(ComparisonMetricDefinition metric, WikiItem item, WikiQualityStats stats, out double value) { double? num = null; switch (metric.Id) { case ComparisonMetricId.ListedDamage: if (item.Kind == WikiItemKind.Weapon) { num = stats.Damage?.ListedCombatTotal; } break; case ComparisonMetricId.Blunt: num = Damage(item, stats, DamageKind.Blunt); break; case ComparisonMetricId.Slash: num = Damage(item, stats, DamageKind.Slash); break; case ComparisonMetricId.Pierce: num = Damage(item, stats, DamageKind.Pierce); break; case ComparisonMetricId.Fire: num = Damage(item, stats, DamageKind.Fire); break; case ComparisonMetricId.Frost: num = Damage(item, stats, DamageKind.Frost); break; case ComparisonMetricId.Lightning: num = Damage(item, stats, DamageKind.Lightning); break; case ComparisonMetricId.Poison: num = Damage(item, stats, DamageKind.Poison); break; case ComparisonMetricId.Spirit: num = Damage(item, stats, DamageKind.Spirit); break; case ComparisonMetricId.PrimaryAttackStamina: if (item.Kind == WikiItemKind.Weapon) { num = stats.PrimaryAttackStamina; } break; case ComparisonMetricId.SecondaryAttackStamina: if (item.Kind == WikiItemKind.Weapon) { num = stats.SecondaryAttackStamina; } break; case ComparisonMetricId.Durability: num = stats.Durability; break; case ComparisonMetricId.Block: { WikiItemKind kind = item.Kind; if ((uint)(kind - 1) <= 1u) { num = stats.Block; } break; } case ComparisonMetricId.ParryBonus: { WikiItemKind kind = item.Kind; if ((uint)(kind - 1) <= 1u) { num = item.ParryBonus; } break; } case ComparisonMetricId.Armor: if (item.Kind == WikiItemKind.Armor) { num = stats.Armor; } break; case ComparisonMetricId.FoodHealth: if (item.Kind == WikiItemKind.Food) { num = item.FoodHealth; } break; case ComparisonMetricId.FoodStamina: if (item.Kind == WikiItemKind.Food) { num = item.FoodStamina; } break; case ComparisonMetricId.FoodEitr: if (item.Kind == WikiItemKind.Food) { num = item.FoodEitr; } break; case ComparisonMetricId.FoodDuration: if (item.Kind == WikiItemKind.Food) { num = item.FoodDurationSeconds; } break; case ComparisonMetricId.FoodRegeneration: if (item.Kind == WikiItemKind.Food) { num = item.FoodRegeneration; } break; case ComparisonMetricId.MovementModifier: num = item.MovementModifier * 100.0; break; case ComparisonMetricId.Weight: num = item.Weight; break; } value = num.GetValueOrDefault(); if (num.HasValue && !double.IsNaN(value)) { return !double.IsInfinity(value); } return false; } public static int Compare(ComparisonMetricDefinition metric, double left, double right) { int num = left.CompareTo(right); if (!metric.HigherRanksFirst) { return -num; } return num; } public static double Improvement(ComparisonMetricDefinition metric, double candidate, double baseline) { if (!metric.HigherRanksFirst) { return baseline - candidate; } return candidate - baseline; } private static bool Resolved(ComparisonMetricId id, out ComparisonMetricDefinition? metric, out string diagnostic) { metric = Definition(id); diagnostic = string.Empty; return true; } private static double? Damage(WikiItem item, WikiQualityStats stats, DamageKind kind) { if (item.Kind != WikiItemKind.Weapon || stats.Damage == null) { return null; } return stats.Damage.Get(kind); } private static ComparisonMetricDefinition Definition(ComparisonMetricId id) { return id switch { ComparisonMetricId.ListedDamage => new ComparisonMetricDefinition(id, "Listed combat damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Blunt => new ComparisonMetricDefinition(id, "Blunt damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Slash => new ComparisonMetricDefinition(id, "Slash damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Pierce => new ComparisonMetricDefinition(id, "Pierce damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Fire => new ComparisonMetricDefinition(id, "Fire damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Frost => new ComparisonMetricDefinition(id, "Frost damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Lightning => new ComparisonMetricDefinition(id, "Lightning damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Poison => new ComparisonMetricDefinition(id, "Poison damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.Spirit => new ComparisonMetricDefinition(id, "Spirit damage", higherRanksFirst: true, string.Empty, "Not considered: enemy resistance, status effects, skill scaling, attack timing or chains, stamina efficiency, or real DPS."), ComparisonMetricId.PrimaryAttackStamina => new ComparisonMetricDefinition(id, "Primary attack stamina", higherRanksFirst: false, string.Empty, "Not considered: damage per attack, attack timing or chains, regeneration, skill scaling, or real DPS."), ComparisonMetricId.SecondaryAttackStamina => new ComparisonMetricDefinition(id, "Secondary attack stamina", higherRanksFirst: false, string.Empty, "Not considered: damage per attack, attack timing or chains, regeneration, skill scaling, or real DPS."), ComparisonMetricId.Durability => new ComparisonMetricDefinition(id, "Durability", higherRanksFirst: true, string.Empty, "This is a single loaded field, not an overall item score."), ComparisonMetricId.Block => new ComparisonMetricDefinition(id, "Block", higherRanksFirst: true, string.Empty, "Not considered: resistances, stagger behavior, enemy attack mix, player skill, or effective survivability."), ComparisonMetricId.ParryBonus => new ComparisonMetricDefinition(id, "Parry bonus", higherRanksFirst: true, "x", "Not considered: resistances, stagger behavior, enemy attack mix, player skill, or effective survivability."), ComparisonMetricId.Armor => new ComparisonMetricDefinition(id, "Armor", higherRanksFirst: true, string.Empty, "Not considered: resistances, stagger behavior, enemy attack mix, player skill, or effective survivability."), ComparisonMetricId.FoodHealth => new ComparisonMetricDefinition(id, "Food health", higherRanksFirst: true, string.Empty, "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."), ComparisonMetricId.FoodStamina => new ComparisonMetricDefinition(id, "Food stamina", higherRanksFirst: true, string.Empty, "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."), ComparisonMetricId.FoodEitr => new ComparisonMetricDefinition(id, "Food eitr", higherRanksFirst: true, string.Empty, "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."), ComparisonMetricId.FoodDuration => new ComparisonMetricDefinition(id, "Food duration", higherRanksFirst: true, " s", "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."), ComparisonMetricId.FoodRegeneration => new ComparisonMetricDefinition(id, "Food regeneration", higherRanksFirst: true, string.Empty, "Not considered: meal combinations, acquisition difficulty, progression, or effects outside the selected loaded field."), ComparisonMetricId.MovementModifier => new ComparisonMetricDefinition(id, "Movement modifier", higherRanksFirst: true, "%", "This is a single loaded field, not an overall item score."), _ => new ComparisonMetricDefinition(id, "Weight", higherRanksFirst: false, string.Empty, "This is a single loaded field, not an overall item score."), }; } } public enum WikiContextKind { Held, Equipped, ActiveFood, CurrentStation } public sealed class WikiContextItem { public string ItemId { get; } public string Role { get; } public double? CurrentHealth { get; } public double? CurrentStamina { get; } public double? CurrentEitr { get; } public double? RemainingSeconds { get; } public WikiContextItem(string itemId, string role, double? currentHealth = null, double? currentStamina = null, double? currentEitr = null, double? remainingSeconds = null) { ItemId = itemId ?? string.Empty; Role = (string.IsNullOrWhiteSpace(role) ? "context item" : role); CurrentHealth = Finite(currentHealth); CurrentStamina = Finite(currentStamina); CurrentEitr = Finite(currentEitr); RemainingSeconds = Finite(remainingSeconds, nonNegative: true); } private static double? Finite(double? value, bool nonNegative = false) { if (value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value) && (!nonNegative || !(value.Value < 0.0))) { return value; } return null; } } public sealed class WikiContextSnapshot { private readonly IReadOnlyList items; public WikiContextKind Kind { get; } public bool PlayerContextAvailable { get; } public IReadOnlyList Items => items; public string? StationName { get; } public string? StationInternalName { get; } public int IsolatedErrors { get; } public WikiContextSnapshot(WikiContextKind kind, bool playerContextAvailable, IEnumerable? items = null, string? stationName = null, string? stationInternalName = null, int isolatedErrors = 0) { Kind = kind; PlayerContextAvailable = playerContextAvailable; this.items = Array.AsReadOnly((items ?? Array.Empty()).OfType().ToArray()); StationName = (string.IsNullOrWhiteSpace(stationName) ? null : stationName); StationInternalName = (string.IsNullOrWhiteSpace(stationInternalName) ? null : stationInternalName); IsolatedErrors = Math.Max(0, isolatedErrors); } } public sealed class WikiEngineOptions { public bool ShowInternalNames { get; set; } public bool ShowSourceMod { get; set; } = true; public bool ShowComparisonAssumptions { get; set; } = true; public int MaximumAlternativeResults { get; set; } = 5; public bool FuzzyMatchingEnabled { get; set; } = true; public double FuzzyMatchThreshold { get; set; } = 0.82; public double AmbiguityMargin { get; set; } = 0.05; } public sealed class WikiEngine { private sealed class FilterExpression { private static readonly Dictionary Categories = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["sword"] = "sword", ["swords"] = "sword", ["axe"] = "axe", ["axes"] = "axe", ["bow"] = "bow", ["bows"] = "bow", ["spear"] = "spear", ["spears"] = "spear", ["mace"] = "club", ["maces"] = "club", ["club"] = "club", ["clubs"] = "club", ["knife"] = "knife", ["knives"] = "knife", ["polearm"] = "polearm", ["polearms"] = "polearm" }; private static readonly Dictionary Kinds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["weapon"] = WikiItemKind.Weapon, ["weapons"] = WikiItemKind.Weapon, ["shield"] = WikiItemKind.Shield, ["shields"] = WikiItemKind.Shield, ["armor"] = WikiItemKind.Armor, ["armour"] = WikiItemKind.Armor, ["food"] = WikiItemKind.Food, ["foods"] = WikiItemKind.Food, ["material"] = WikiItemKind.Material, ["materials"] = WikiItemKind.Material, ["tool"] = WikiItemKind.Tool, ["tools"] = WikiItemKind.Tool }; private static readonly Dictionary Damages = (from DamageKind kind in Enum.GetValues(typeof(DamageKind)) where kind != DamageKind.Chop && kind != DamageKind.Pickaxe select kind).ToDictionary((DamageKind kind) => kind.ToString(), (DamageKind kind) => kind, StringComparer.OrdinalIgnoreCase); public bool IsSupported { get; private set; } public string Diagnostic { get; private set; } = string.Empty; public WikiItemKind? Kind { get; private set; } public string? Category { get; private set; } public DamageKind? Damage { get; private set; } public string? NumericField { get; private set; } public string? Operator { get; private set; } public double? NumericValue { get; private set; } public static FilterExpression Parse(string expression) { FilterExpression filterExpression = new FilterExpression(); string text = TextNormalizer.Normalize(expression); string[] array = text.Split(new char[1] { ' ' }, StringSplitOptions.RemoveEmptyEntries); foreach (string key in array) { if (Categories.TryGetValue(key, out string value)) { filterExpression.Category = value; } if (Kinds.TryGetValue(key, out var value2)) { filterExpression.Kind = value2; } if (Damages.TryGetValue(key, out var value3)) { filterExpression.Damage = value3; } } Match match = Regex.Match(text, "(?above|below|at least|at most)\\s+(?-?[0-9]+(?:\\.[0-9]+)?)\\s+(?primary stamina|secondary stamina|total damage|listed damage|health|stamina|eitr|duration|regeneration|regen|armor|block|durability|weight|movement|blunt|slash|pierce|fire|frost|lightning|poison|spirit)\\b", RegexOptions.IgnoreCase); if (!match.Success) { match = Regex.Match(text, "(?primary stamina|secondary stamina|total damage|listed damage|health|stamina|eitr|duration|regeneration|regen|armor|block|durability|weight|movement|blunt|slash|pierce|fire|frost|lightning|poison|spirit)\\s+(?above|below|at least|at most)\\s+(?-?[0-9]+(?:\\.[0-9]+)?)\\b", RegexOptions.IgnoreCase); } if (match.Success && double.TryParse(match.Groups["value"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { filterExpression.NumericField = NormalizeNumericField(match.Groups["field"].Value); filterExpression.Operator = match.Groups["op"].Value.ToLowerInvariant(); filterExpression.NumericValue = result; if (!filterExpression.Kind.HasValue && filterExpression.Category == null) { bool flag; switch (filterExpression.NumericField) { case "health": case "eitr": case "duration": case "regeneration": flag = true; break; default: flag = false; break; } if (flag) { filterExpression.Kind = WikiItemKind.Food; } switch (filterExpression.NumericField) { case "poison": case "spirit": case "damage": case "pierce": case "blunt": case "frost": case "slash": case "primary stamina": case "secondary stamina": case "fire": case "lightning": flag = true; break; default: flag = false; break; } if (flag) { filterExpression.Kind = WikiItemKind.Weapon; } if (filterExpression.NumericField == "armor") { filterExpression.Kind = WikiItemKind.Armor; } } } filterExpression.IsSupported = filterExpression.Kind.HasValue || filterExpression.Category != null || filterExpression.Damage.HasValue || filterExpression.NumericValue.HasValue; filterExpression.Diagnostic = (filterExpression.IsSupported ? "Supported filter parsed." : "Supported filters are item category, combat damage type, and loaded numeric fields such as damage, stamina, armor, block, durability, weight, movement, health, eitr, duration, or regeneration using above, below, at least, or at most."); return filterExpression; } private static string NormalizeNumericField(string field) { string text = TextNormalizer.Normalize(field); if ((text == "total damage" || text == "listed damage") ? true : false) { return "damage"; } if (text == "regen") { return "regeneration"; } return text; } } private sealed class StationResolution { public ResolutionStatus Status { get; } public IReadOnlyList Names { get; } public string Diagnostic { get; } public StationResolution(ResolutionStatus status, IEnumerable names, string diagnostic) { Status = status; Names = Array.AsReadOnly(names.ToArray()); Diagnostic = diagnostic; } } private sealed class SourceEntityResolution { public ResolutionStatus Status { get; } public IReadOnlyList Names { get; } public SourceEntityResolution(ResolutionStatus status, IEnumerable names) { Status = status; Names = Array.AsReadOnly(names.ToArray()); } } private sealed class SourceDisplayGroup { public WikiAcquisitionSource Source { get; } public int VariantCount { get; } public SourceDisplayGroup(WikiAcquisitionSource source, int variantCount) { Source = source; VariantCount = Math.Max(1, variantCount); } } private const int MaximumContextEntries = 16; private const int MaximumDiagnosticRows = 20; private static readonly DamageKind[] CombatDamageOrder = new DamageKind[8] { DamageKind.Blunt, DamageKind.Slash, DamageKind.Pierce, DamageKind.Fire, DamageKind.Frost, DamageKind.Lightning, DamageKind.Poison, DamageKind.Spirit }; private readonly EntityResolver resolver; private readonly WikiEngineOptions options; public WikiEngine(EntityResolver resolver, WikiEngineOptions options) { this.resolver = resolver ?? throw new ArgumentNullException("resolver"); this.options = options ?? throw new ArgumentNullException("options"); } public WikiAnswer Execute(WikiIndex index, WikiQuery query) { return Execute(index, query, WikiVisibilitySnapshot.AllLoaded); } public WikiAnswer Execute(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (index == null) { throw new ArgumentNullException("index"); } if (query == null) { throw new ArgumentNullException("query"); } if (visibility == null) { throw new ArgumentNullException("visibility"); } if (visibility.IsRestricted && !visibility.CharacterStateAvailable && query.Intent != WikiIntent.Help && query.Intent != WikiIntent.Status) { return new WikiAnswer("Character knowledge unavailable", new string[2] { $"{visibility.Policy} requires an active local character.", "The complete runtime index remains loaded, but this query was not allowed to read it." }, null, new string[1] { "Enter a world with a character or set SpoilerPolicy = AllLoaded." }); } switch (query.Intent) { case WikiIntent.Help: return Help(visibility); case WikiIntent.Status: return Status(index, visibility); case WikiIntent.ItemInfo: return WithItem(index, query, visibility, (WikiItem item) => ItemInfo(index, item, query.Quality ?? 1)); case WikiIntent.RecipeLookup: return WithItem(index, query, visibility, (WikiItem item) => Recipes(index, item, visibility)); case WikiIntent.UpgradeLookup: return WithItem(index, query, visibility, (WikiItem item) => Upgrades(index, item, visibility)); case WikiIntent.MaterialUsage: return WithItem(index, query, visibility, (WikiItem item) => Uses(index, item, visibility)); case WikiIntent.CraftingPlan: return WithItem(index, query, visibility, (WikiItem item) => Plan(index, item, query.Quantity ?? 1, query.Quality ?? 1, visibility)); case WikiIntent.AcquisitionSource: return WithItem(index, query, visibility, (WikiItem item) => AcquisitionSources(index, item, SourceKindFilter(query), visibility)); case WikiIntent.SourceInventory: return SourceInventory(index, query, visibility); case WikiIntent.CraftingStation: return StationQuery(index, query, visibility); case WikiIntent.StrongerAlternatives: return WithItem(index, query, visibility, (WikiItem item) => Stronger(index, item, query, visibility)); case WikiIntent.ItemComparison: return Compare(index, query, visibility); case WikiIntent.CategoryRanking: return RankCategory(index, query, visibility); case WikiIntent.ComparisonExplanation: return ExplainComparison(index, query, visibility); case WikiIntent.Find: return Find(index, query, visibility); case WikiIntent.Diagnostics: return Diagnostics(index, visibility); case WikiIntent.ModReport: return ModReport(index, visibility); case WikiIntent.UnsupportedReport: return UnsupportedReport(index, visibility); case WikiIntent.ContextHeld: case WikiIntent.ContextEquipped: case WikiIntent.ContextFood: case WikiIntent.ContextStation: return new WikiAnswer("Context adapter required", new string[1] { "This shortcut requires the read-only in-game context adapter." }); case WikiIntent.More: return new WikiAnswer("Pagination", new string[1] { "No pagination context was supplied to the query engine." }); case WikiIntent.Reindex: return new WikiAnswer("Reindex", new string[1] { "Reindexing is handled by the game lifecycle adapter." }); case WikiIntent.Reload: return new WikiAnswer("Reload", new string[1] { "Configuration reload is handled by the plugin adapter." }); default: return new WikiAnswer("Unsupported question", new string[1] { query.Diagnostic }, null, new string[1] { "Use /wiki help for supported forms." }); } } public WikiAnswer ExecuteContext(WikiIndex index, WikiQuery query, WikiContextSnapshot context, WikiVisibilitySnapshot visibility) { if (index == null) { throw new ArgumentNullException("index"); } if (query == null) { throw new ArgumentNullException("query"); } if (context == null) { throw new ArgumentNullException("context"); } if (visibility == null) { throw new ArgumentNullException("visibility"); } if (!IsContextIntent(query.Intent)) { return new WikiAnswer("Invalid context query", new string[1] { "The supplied query is not a context shortcut." }); } if (visibility.IsRestricted && !visibility.CharacterStateAvailable) { return new WikiAnswer("Character knowledge unavailable", new string[1] { $"{visibility.Policy} requires an active local character." }); } if (!context.PlayerContextAvailable) { return new WikiAnswer("Player context unavailable", new string[1] { "Enter a world with an active local character before using this shortcut." }); } if (!ContextKindMatches(query.Intent, context.Kind)) { return new WikiAnswer("Context mismatch", new string[1] { "The runtime context did not match the requested shortcut; no fallback lookup was attempted." }); } return query.Intent switch { WikiIntent.ContextHeld => HeldContext(index, context, visibility), WikiIntent.ContextEquipped => ItemContextList(index, context, visibility, "Equipped items"), WikiIntent.ContextFood => FoodContext(index, context, visibility), _ => StationContext(index, context, visibility), }; } private WikiAnswer HeldContext(WikiIndex index, WikiContextSnapshot context, WikiVisibilitySnapshot visibility) { if (context.Items.Count == 0) { return new WikiAnswer("Held item", new string[1] { "No item is reported in either direct hand slot or among equipped hand-capable items." }); } WikiContextItem wikiContextItem = context.Items[0]; if (!index.Items.TryGetValue(wikiContextItem.ItemId, out WikiItem value)) { return ContextUnavailableItem("Held item", context, "The current held item is not represented in the runtime wiki index."); } if (!visibility.IsVisible(value)) { return ContextUnavailableItem("Held item", context, $"The current held item is hidden by {visibility.Policy}."); } WikiAnswer wikiAnswer = ItemInfo(index, value, 1); string[] warnings = wikiAnswer.Warnings.Concat(ContextWarnings(context)).ToArray(); return new WikiAnswer("Held: " + wikiAnswer.Title, new string[1] { "Context: " + wikiContextItem.Role + "." }.Concat(wikiAnswer.Lines), warnings, wikiAnswer.Suggestions); } private WikiAnswer ItemContextList(WikiIndex index, WikiContextSnapshot context, WikiVisibilitySnapshot visibility, string title) { List<(WikiContextItem, WikiItem)> list = new List<(WikiContextItem, WikiItem)>(); int num = 0; int num2 = 0; foreach (WikiContextItem item in context.Items.Take(16)) { if (!index.Items.TryGetValue(item.ItemId, out WikiItem value)) { num2++; } else if (!visibility.IsVisible(value)) { num++; } else { list.Add((item, value)); } } List list2 = list.Select<(WikiContextItem, WikiItem), string>(((WikiContextItem Entry, WikiItem Item) tuple) => "- " + Label(tuple.Item) + " | " + tuple.Item.Category + " | " + tuple.Entry.Role).ToList(); if (context.Items.Count == 0) { list2.Add("No equipped items are reported by the active character inventory."); } if (num > 0) { list2.Add(string.Format("{0} equipped item{1} hidden by {2}.", num, (num == 1) ? " is" : "s are", visibility.Policy)); } if (num2 > 0) { list2.Add(string.Format("{0} equipped item{1} not represented in the runtime wiki index.", num2, (num2 == 1) ? " is" : "s are")); } if (context.Items.Count > 16) { list2.Add($"Only the first {16} context entries were evaluated."); } list2.AddRange(ContextWarnings(context)); return new WikiAnswer($"{title} ({list.Count} visible)", list2); } private WikiAnswer FoodContext(WikiIndex index, WikiContextSnapshot context, WikiVisibilitySnapshot visibility) { List<(WikiContextItem, WikiItem)> list = new List<(WikiContextItem, WikiItem)>(); int num = 0; int num2 = 0; foreach (WikiContextItem item in context.Items.Take(16)) { if (!index.Items.TryGetValue(item.ItemId, out WikiItem value)) { num2++; } else if (!visibility.IsVisible(value)) { num++; } else { list.Add((item, value)); } } List list2 = list.Select<(WikiContextItem, WikiItem), string>(delegate((WikiContextItem Entry, WikiItem Item) tuple) { List list3 = new List(); if (tuple.Entry.CurrentHealth.HasValue) { list3.Add("health " + Number(tuple.Entry.CurrentHealth.Value)); } if (tuple.Entry.CurrentStamina.HasValue) { list3.Add("stamina " + Number(tuple.Entry.CurrentStamina.Value)); } if (tuple.Entry.CurrentEitr.HasValue) { list3.Add("eitr " + Number(tuple.Entry.CurrentEitr.Value)); } if (tuple.Entry.RemainingSeconds.HasValue) { list3.Add("remaining " + Number(tuple.Entry.RemainingSeconds.Value / 60.0) + " min"); } return "- " + Label(tuple.Item) + ": " + ((list3.Count == 0) ? "current contribution unavailable" : string.Join(" | ", list3)); }).ToList(); if (context.Items.Count == 0) { list2.Add("No active consumed foods are reported by the current character."); } if (num > 0) { list2.Add(string.Format("{0} active food item{1} hidden by {2}.", num, (num == 1) ? " is" : "s are", visibility.Policy)); } if (num2 > 0) { list2.Add(string.Format("{0} active food item{1} not represented in the runtime wiki index.", num2, (num2 == 1) ? " is" : "s are")); } list2.AddRange(ContextWarnings(context)); return new WikiAnswer($"Active food ({list.Count} visible)", list2, new string[1] { "Values are the character's loaded current food contributions and remaining time; no food state is changed." }); } private WikiAnswer StationContext(WikiIndex index, WikiContextSnapshot context, WikiVisibilitySnapshot visibility) { if (string.IsNullOrWhiteSpace(context.StationName)) { return new WikiAnswer("Current station", new string[2] { "No current crafting-station interaction is active.", "For this shortcut, 'nearby' means the station currently being used; Mimir's Well does not scan the scene or infer distance." }.Concat(ContextWarnings(context))); } WikiAnswer wikiAnswer = StationContents(index, context.StationName, visibility); return new WikiAnswer("Current station: " + wikiAnswer.Title, wikiAnswer.Lines, wikiAnswer.Warnings.Concat(ContextWarnings(context)), wikiAnswer.Suggestions); } private static WikiAnswer ContextUnavailableItem(string title, WikiContextSnapshot context, string message) { return new WikiAnswer(title, new string[1] { message }.Concat(ContextWarnings(context))); } private static IEnumerable ContextWarnings(WikiContextSnapshot context) { if (context.IsolatedErrors > 0) { yield return string.Format("{0} malformed context entr{1} isolated.", context.IsolatedErrors, (context.IsolatedErrors == 1) ? "y was" : "ies were"); } } private static bool IsContextIntent(WikiIntent intent) { if ((uint)(intent - 15) <= 3u) { return true; } return false; } private static bool ContextKindMatches(WikiIntent intent, WikiContextKind kind) { if ((intent != WikiIntent.ContextHeld || kind != WikiContextKind.Held) && (intent != WikiIntent.ContextEquipped || kind != WikiContextKind.Equipped) && (intent != WikiIntent.ContextFood || kind != WikiContextKind.ActiveFood)) { if (intent == WikiIntent.ContextStation) { return kind == WikiContextKind.CurrentStation; } return false; } return true; } private WikiAnswer WithItem(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility, Func answer) { if (query.EntityTexts.Count == 0) { return new WikiAnswer("Missing item", new string[1] { "Please provide an item name." }); } ResolutionResult resolutionResult = resolver.Resolve(index, query.EntityTexts[0], visibility.IsVisible); if (resolutionResult.Status == ResolutionStatus.Resolved && resolutionResult.Item != null) { return answer(resolutionResult.Item); } return ResolutionAnswer(query.EntityTexts[0], resolutionResult); } private static WikiAnswer Help(WikiVisibilitySnapshot visibility) { return new WikiAnswer("Commands", new string[16] { "/wiki or /wiki info ", "/wiki recipe | upgrades | uses ", "/wiki plan | plan quality ", "/wiki source | what drops ", "/wiki drops | what does sell", "/wiki mine | fish | what produces ", "/wiki station | compare and ", "/wiki compare and by quality ", "/wiki compare foods by stamina | compare shields by block", "/wiki why is stronger than by ", "/wiki stronger | find ", "/wiki held | equipped | food | station nearby", "/wiki diagnostics | mods | unsupported", "/wiki status | more | reload | reindex", "Natural example: /wiki how do I make an iron sword", "Quote an exact name when needed: /wiki recipe \"Iron Sword\"" }, new string[2] { "Coverage includes crafting, build pieces, processing conversions, reliable direct sources, deterministic environmental drops, and direct fish pickup. Acquisition sources remain separate from recipe methods.", $"Knowledge visibility: {visibility.Policy} ({visibility.Description})." }); } private static WikiAnswer Plan(WikiIndex index, WikiItem item, long quantity, int quality, WikiVisibilitySnapshot visibility) { ProductionPlan productionPlan = new ProductionPlanner().Plan(index, item, quantity, quality, visibility); return new WikiAnswer(productionPlan.Title, productionPlan.Lines, null, null, isPaginated: true); } private static WikiAnswer Status(WikiIndex index, WikiVisibilitySnapshot visibility) { int num = (visibility.IsRestricted ? index.Items.Values.Count(visibility.IsVisible) : index.Items.Count); return new WikiAnswer("Index ready", new string[12] { $"Items: {index.Items.Count} | recipes: {index.Recipes.Count} | conversions: {index.Conversions.Count} | build pieces: {index.Pieces.Count}", string.Format("Knowledge visibility: {0} | visible items: {1}/{2} | character state: {3} | isolated evaluation errors: {4}.", visibility.Policy, num, index.Items.Count, visibility.CharacterStateAvailable ? "available" : "unavailable", visibility.EvaluationErrors), "The complete loaded index is immutable; visibility is applied only while answering each query.", $"Diagnostics: {index.Diagnostics.Count} retained issue records | {index.OmittedDiagnostics} omitted by the 256-record bound.", $"Sources: {index.Sources.Count} | creature: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.CreatureDrop)} | purchased: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.Purchased)} | passive: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.PassivelyProduced)} | harvested: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.Harvested)}", $"Environmental: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.MinedOrDestroyed)} mined/destroyed ({index.Sources.Count((WikiAcquisitionSource source) => source.QuantitySemantics == WikiQuantitySemantics.ExactLoadedRange)} guaranteed-variable) | fishing: {index.Sources.Count((WikiAcquisitionSource source) => source.Kind == WikiSourceKind.Fishing)}", "Planner: enabled | bounds: depth 12, nodes 256, alternatives 8, target 1,000,000, computed 1,000,000,000, lines 200, time 50 ms.", "Comparisons: deterministic explicit metrics and explanations enabled; no DPS or composite best score.", $"Language: {index.Language} | build: {index.BuildDuration.TotalMilliseconds:0} ms", $"Skipped entries: {index.SkippedEntries} | unavailable optional stats: {index.UnavailableStatistics}", index.WorldContextAvailable ? "Runtime calculators: world context available." : "Runtime calculators: menu context; a world-entry refresh is pending.", index.CoverageNote }.Where((string line) => !string.IsNullOrWhiteSpace(line))); } private WikiAnswer ItemInfo(WikiIndex index, WikiItem item, int requestedQuality) { int num = ClampQuality(item, requestedQuality); WikiQualityStats wikiQualityStats = item.StatsAt(num); List list = new List { $"Category: {item.Category} | quality: {num}/{item.MaxQuality}", $"Weight: {Number(item.Weight)} | stack: {item.MaxStack}" }; if (item.Kind == WikiItemKind.Weapon) { if (wikiQualityStats.Damage != null) { list.Add("Listed total attack damage: " + Number(wikiQualityStats.Damage.ListedCombatTotal) + " (" + DamageSummary(wikiQualityStats.Damage) + ")"); } else { list.Add("Listed total attack damage: unavailable for this quality."); } AddNullable(list, "Primary attack stamina", wikiQualityStats.PrimaryAttackStamina); AddNullable(list, "Secondary attack stamina", wikiQualityStats.SecondaryAttackStamina); AddNullable(list, "Durability", wikiQualityStats.Durability); AddNullable(list, "Block", wikiQualityStats.Block); AddNullable(list, "Parry bonus", item.ParryBonus, "x"); } else if (item.Kind == WikiItemKind.Armor || item.Kind == WikiItemKind.Shield) { AddNullable(list, "Armor", wikiQualityStats.Armor); AddNullable(list, "Block", wikiQualityStats.Block); AddNullable(list, "Durability", wikiQualityStats.Durability); AddNullable(list, "Parry bonus", item.ParryBonus, "x"); } else if (item.Kind == WikiItemKind.Food) { AddNullable(list, "Food health", item.FoodHealth); AddNullable(list, "Food stamina", item.FoodStamina); AddNullable(list, "Food eitr", item.FoodEitr); AddNullable(list, "Duration", item.FoodDurationSeconds, " s"); AddNullable(list, "Regeneration", item.FoodRegeneration); } if (Math.Abs(item.MovementModifier) > 0.0001) { list.Add("Movement modifier: " + Number(item.MovementModifier * 100.0) + "%"); } int count = index.RecipesForOutput(item.Id).Count; int count2 = index.ConversionsForOutput(item.Id).Count; int count3 = index.SourcesForOutput(item.Id).Count; list.Add((count == 0 && count2 == 0 && count3 == 0) ? "Production/acquisition: no indexed method found." : string.Format("Production/acquisition: {0} crafting recipe{1}, {2} processing method{3}, {4} direct source{5}.", count, (count == 1) ? string.Empty : "s", count2, (count2 == 1) ? string.Empty : "s", count3, (count3 == 1) ? string.Empty : "s")); AddIdentity(list, item); return new WikiAnswer(item.DisplayName, list, (requestedQuality == num) ? null : new string[1] { $"Requested quality {requestedQuality} was clamped to {num}." }); } private static WikiAnswer Recipes(WikiIndex index, WikiItem item, WikiVisibilitySnapshot visibility) { IReadOnlyList readOnlyList = index.RecipesForOutput(item.Id); IReadOnlyList readOnlyList2 = index.ConversionsForOutput(item.Id); if (readOnlyList.Count == 0 && readOnlyList2.Count == 0) { int count = index.SourcesForOutput(item.Id).Count; return new WikiAnswer(item.DisplayName, new string[1] { "No indexed crafting or input-processing method was found." }, (count <= 0) ? new string[1] { "The item may come from a currently unsupported source system." } : new string[1] { string.Format("{0} direct acquisition source{1} indexed; use /wiki source \"{2}\".", count, (count == 1) ? " is" : "s are", item.DisplayName) }); } List list = new List(); if (readOnlyList.Count > 0 && readOnlyList2.Count > 0) { list.Add("Crafting:"); } for (int i = 0; i < readOnlyList.Count; i++) { WikiRecipe wikiRecipe = readOnlyList[i]; if (readOnlyList.Count > 1) { list.Add($"Recipe {i + 1}: {wikiRecipe.Id}"); } list.Add(CraftAt(wikiRecipe, 1)); list.Add("Initial cost: " + Requirements(wikiRecipe.Requirements, 1, wikiRecipe.RequireOnlyOneIngredient, visibility)); list.Add($"Output: {wikiRecipe.OutputAmount} | maximum quality: {item.MaxQuality}"); } if (readOnlyList2.Count > 0) { if (readOnlyList.Count > 0) { list.Add("Processing:"); } for (int j = 0; j < readOnlyList2.Count; j++) { WikiConversion wikiConversion = readOnlyList2[j]; if (readOnlyList2.Count > 1) { list.Add($"Method {j + 1}: {wikiConversion.StationName} ({ProcessLabel(wikiConversion.Kind)})"); } else { list.Add("Process at: " + wikiConversion.StationName + " (" + ProcessLabel(wikiConversion.Kind) + ")"); } list.Add(ConversionRequirements(wikiConversion, visibility)); string arg = (wikiConversion.DurationSeconds.HasValue ? (" | time: " + Number(wikiConversion.DurationSeconds.Value) + " s") : string.Empty); list.Add($"Output: {wikiConversion.OutputAmount} {wikiConversion.OutputName}{arg}"); } } string[] suggestions = ((item.MaxQuality <= 1) ? null : new string[1] { "Use /wiki upgrades \"" + item.DisplayName + "\" for per-quality costs." }); return new WikiAnswer(item.DisplayName, list, null, suggestions); } private static WikiAnswer Upgrades(WikiIndex index, WikiItem item, WikiVisibilitySnapshot visibility) { if (item.MaxQuality <= 1) { return new WikiAnswer(item.DisplayName, new string[1] { "This item has no meaningful quality progression." }); } IReadOnlyList readOnlyList = index.RecipesForOutput(item.Id); if (readOnlyList.Count == 0) { return new WikiAnswer(item.DisplayName, new string[1] { "No enabled ObjectDB recipe is available for upgrade costs." }); } List list = new List(); for (int i = 0; i < readOnlyList.Count; i++) { WikiRecipe wikiRecipe = readOnlyList[i]; if (readOnlyList.Count > 1) { list.Add($"Recipe {i + 1}: {wikiRecipe.Id}"); } for (int j = 2; j <= item.MaxQuality; j++) { list.Add($"To quality {j}: {Requirements(wikiRecipe.Requirements, j, wikiRecipe.RequireOnlyOneIngredient, visibility)} | {CraftAt(wikiRecipe, j)}"); } } return new WikiAnswer(item.DisplayName + " upgrades", list, new string[1] { "Costs are per target quality, calculated by Valheim's loaded Requirement.GetAmount data." }); } private static WikiAnswer Uses(WikiIndex index, WikiItem material, WikiVisibilitySnapshot visibility) { WikiRecipe[] array = (from recipe in index.RecipesUsing(material.Id) where visibility.IsVisible(recipe.OutputItemId) select recipe).OrderBy((WikiRecipe recipe) => recipe.OutputName, StringComparer.CurrentCultureIgnoreCase).ToArray(); WikiPiece[] array2 = index.PiecesUsing(material.Id).Where(visibility.IsVisible).OrderBy((WikiPiece piece) => piece.DisplayName, StringComparer.CurrentCultureIgnoreCase) .ToArray(); WikiConversion[] array3 = (from conversion in index.ConversionsUsing(material.Id) where visibility.IsVisible(conversion.OutputItemId) select conversion).OrderBy((WikiConversion conversion) => conversion.OutputName, StringComparer.CurrentCultureIgnoreCase).ToArray(); List list = new List(); if (array.Length != 0) { list.Add("Crafting:"); list.AddRange(array.Select((WikiRecipe recipe) => $"- {recipe.OutputName}: {RequirementAmount(recipe, material.Id, 1)} {material.DisplayName} | {StationText(recipe.StationName, recipe.StationLevelForTargetQuality(1))}")); } if (array2.Length != 0) { list.Add("Building:"); list.AddRange(array2.Select((WikiPiece piece) => $"- {piece.DisplayName}: {piece.Requirements.First((WikiPieceRequirement requirement) => requirement.ItemId.Equals(material.Id, StringComparison.OrdinalIgnoreCase)).Amount} {material.DisplayName}")); } if (array3.Length != 0) { list.Add("Processing:"); list.AddRange(array3.Select((WikiConversion conversion) => "- " + conversion.OutputName + ": " + ConversionUse(conversion, material.Id) + " | " + conversion.StationName + " (" + ProcessLabel(conversion.Kind) + ")")); } if (list.Count == 0) { list.Add("No indexed crafting recipe, build piece, or input-processing method consumes this item."); } return new WikiAnswer("Uses for " + material.DisplayName, list); } private WikiAnswer AcquisitionSources(WikiIndex index, WikiItem item, WikiSourceKind? sourceKind, WikiVisibilitySnapshot visibility) { WikiAcquisitionSource[] source = (from wikiAcquisitionSource in index.SourcesForOutput(item.Id) where !sourceKind.HasValue || wikiAcquisitionSource.Kind == sourceKind.Value orderby wikiAcquisitionSource.Kind select wikiAcquisitionSource).ThenBy((WikiAcquisitionSource wikiAcquisitionSource) => wikiAcquisitionSource.SourceName, StringComparer.CurrentCultureIgnoreCase).ThenBy((WikiAcquisitionSource wikiAcquisitionSource) => wikiAcquisitionSource.Id, StringComparer.OrdinalIgnoreCase).ToArray(); WikiRecipe[] array = (sourceKind.HasValue ? Array.Empty() : index.RecipesForOutput(item.Id).ToArray()); WikiConversion[] array2 = (sourceKind.HasValue ? Array.Empty() : index.ConversionsForOutput(item.Id).ToArray()); List list = new List(); if (array.Length != 0) { list.Add("Crafted:"); list.AddRange(array.Select((WikiRecipe recipe) => $"- {StationText(recipe.StationName, recipe.StationLevelForTargetQuality(1))}: {Requirements(recipe.Requirements, 1, recipe.RequireOnlyOneIngredient, visibility)} -> {recipe.OutputAmount} {item.DisplayName}")); } if (array2.Length != 0) { list.Add("Processed:"); list.AddRange(array2.Select((WikiConversion conversion) => "- " + conversion.StationName + " (" + ProcessLabel(conversion.Kind) + "): " + ConversionSummary(conversion, visibility))); } foreach (IGrouping item2 in from wikiAcquisitionSource in source group wikiAcquisitionSource by wikiAcquisitionSource.Kind) { list.Add(SourceKindLabel(item2.Key) + ":"); list.AddRange(from display in CollapseSourceVariants(item2) select "- " + SourceLabel(display.Source) + ": " + SourceDetails(display.Source, includeOutputName: false, visibility, display.VariantCount)); } if (list.Count == 0) { string text = (sourceKind.HasValue ? SourceKindLabel(sourceKind.Value).ToLowerInvariant() : "acquisition"); list.Add("No indexed " + text + " source was found."); } string[] warnings = ((!sourceKind.HasValue) ? new string[1] { "Chance-based, weighted, multi-output, or zero-minimum environmental tables, fish extra drops/bait probabilities, containers, locations, biome inference, and procedural loot are excluded." } : new string[1] { "Only " + SourceKindLabel(sourceKind.Value).ToLowerInvariant() + " sources were requested; other acquisition categories were not shown." }); return new WikiAnswer("Sources for " + item.DisplayName, list, warnings); } private WikiAnswer SourceInventory(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (query.EntityTexts.Count == 0) { return new WikiAnswer("Missing source", new string[1] { "Please provide a creature or trader name." }); } WikiSourceKind? kind = SourceKindFilter(query); WikiAcquisitionSource[] array = (from source in index.Sources where visibility.IsVisible(source.OutputItemId) where !kind.HasValue || source.Kind == kind.Value select source).ToArray(); if (array.Length == 0) { return new WikiAnswer("No indexed sources", new string[1] { "No source entries of the requested category are present in the runtime index." }); } string text = query.EntityTexts[0]; string text2 = TextNormalizer.Normalize(text); if (kind == WikiSourceKind.Purchased) { switch (text2) { case "trader": case "traders": case "all traders": return SourceInventoryAnswer("Trader goods", array, visibility); } } SourceEntityResolution resolution = ResolveSourceEntity(array, text); if (resolution.Status == ResolutionStatus.Resolved) { WikiAcquisitionSource[] values = array.Where((WikiAcquisitionSource source) => string.Equals(source.SourceName, resolution.Names[0], StringComparison.CurrentCultureIgnoreCase)).ToArray(); return SourceInventoryAnswer(resolution.Names[0] + " sources", values, visibility); } if (resolution.Status == ResolutionStatus.Ambiguous) { return new WikiAnswer("Several possible sources", resolution.Names.Select((string name, int indexValue) => $"{indexValue + 1}. {name}"), null, new string[1] { "Try an exact source name, for example /wiki " + ((kind == WikiSourceKind.Purchased) ? "sells" : "drops") + " \"" + resolution.Names[0] + "\"." }); } return new WikiAnswer("Source not found", new string[1] { "No confident source match for '" + text + "'." }, null, new string[1] { (kind == WikiSourceKind.Purchased) ? "Try /wiki what does the trader sell." : "Check the creature name or use /wiki source ." }); } private WikiAnswer SourceInventoryAnswer(string title, IEnumerable values, WikiVisibilitySnapshot visibility) { IReadOnlyList readOnlyList = CollapseSourceVariants(values.OrderBy((WikiAcquisitionSource source) => source.SourceName, StringComparer.CurrentCultureIgnoreCase).ThenBy((WikiAcquisitionSource source) => source.OutputName, StringComparer.CurrentCultureIgnoreCase).ToArray()); List list = new List(); foreach (IGrouping item in readOnlyList.GroupBy((SourceDisplayGroup display) => display.Source.SourceName, StringComparer.CurrentCultureIgnoreCase)) { if (readOnlyList.Select((SourceDisplayGroup display) => display.Source.SourceName).Distinct(StringComparer.CurrentCultureIgnoreCase).Skip(1) .Any()) { list.Add(SourceLabel(item.First().Source) + ":"); } list.AddRange(item.Select((SourceDisplayGroup display) => "- " + SourceDetails(display.Source, includeOutputName: true, visibility, display.VariantCount))); } return new WikiAnswer($"{title} ({readOnlyList.Count})", list); } private SourceEntityResolution ResolveSourceEntity(IEnumerable values, string text) { bool wasQuoted; string query = TextNormalizer.Unquote(text, out wasQuoted); string normalized = TextNormalizer.Normalize(query); var source = (from @group in values.GroupBy((WikiAcquisitionSource wikiAcquisitionSource) => wikiAcquisitionSource.SourceName, StringComparer.CurrentCultureIgnoreCase) select new { Name = @group.Key, InternalNames = @group.Select((WikiAcquisitionSource wikiAcquisitionSource) => wikiAcquisitionSource.SourceInternalName).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() }).OrderBy(entity => entity.Name, StringComparer.CurrentCultureIgnoreCase).ToArray(); string[] array = (from entity in source where string.Equals(entity.Name, query, StringComparison.CurrentCultureIgnoreCase) || TextNormalizer.Normalize(entity.Name) == normalized || entity.InternalNames.Any((string name) => string.Equals(name, query, StringComparison.OrdinalIgnoreCase) || TextNormalizer.Normalize(name) == normalized) select entity.Name).ToArray(); if (array.Length != 0) { return new SourceEntityResolution((array.Length != 1) ? ResolutionStatus.Ambiguous : ResolutionStatus.Resolved, array); } if (wasQuoted || !options.FuzzyMatchingEnabled) { return new SourceEntityResolution(ResolutionStatus.NotFound, Array.Empty()); } var array2 = (from entity in source select new { Name = entity.Name, Score = new double[1] { FuzzyMatcher.Similarity(query, entity.Name) }.Concat(entity.InternalNames.Select((string name) => FuzzyMatcher.Similarity(query, name))).Max() } into candidate where candidate.Score >= options.FuzzyMatchThreshold orderby candidate.Score descending select candidate).ThenBy(candidate => candidate.Name, StringComparer.CurrentCultureIgnoreCase).Take(5).ToArray(); if (array2.Length == 0) { return new SourceEntityResolution(ResolutionStatus.NotFound, Array.Empty()); } bool flag = array2.Length > 1 && array2[0].Score - array2[1].Score <= options.AmbiguityMargin; int status = (flag ? 1 : 0); IEnumerable names; if (!flag) { IEnumerable enumerable = new string[1] { array2[0].Name }; names = enumerable; } else { names = array2.Select(candidate => candidate.Name); } return new SourceEntityResolution((ResolutionStatus)status, names); } private string SourceLabel(WikiAcquisitionSource source) { if (!options.ShowSourceMod || string.IsNullOrWhiteSpace(source.SourceMod)) { return source.SourceName; } return source.SourceName + " [" + source.SourceMod + "]"; } private static string SourceDetails(WikiAcquisitionSource source, bool includeOutputName, WikiVisibilitySnapshot visibility, int variantCount = 1) { string text = ((source.MinimumAmount == source.MaximumAmount) ? source.MinimumAmount.ToString(CultureInfo.InvariantCulture) : $"{source.MinimumAmount}-{source.MaximumAmount}"); string text2 = (includeOutputName ? (" " + source.OutputName) : string.Empty); List list = new List(); switch (source.Kind) { case WikiSourceKind.Purchased: { string text4 = (source.Price.HasValue ? string.Format(" for {0} {1}", source.Price.Value, (source.CostItemId == null) ? (source.CostItemName ?? "currency") : visibility.ItemName(source.CostItemId, source.CostItemName ?? "currency")) : string.Empty); list.Add(text + text2 + text4); break; } case WikiSourceKind.PassivelyProduced: { string text3 = (source.ProductionIntervalSeconds.HasValue ? (" per " + Number(source.ProductionIntervalSeconds.Value) + " s") : string.Empty); list.Add(text + text2 + text3); break; } case WikiSourceKind.Harvested: list.Add(source.WorldScaled ? ("base yield " + text + text2) : (text + text2)); break; case WikiSourceKind.MinedOrDestroyed: list.Add(source.ConfiguredMinimumAmount.HasValue ? ("loaded yield " + text + text2) : (source.WorldScaled ? ("base yield " + text + text2) : (text + text2))); break; case WikiSourceKind.Fishing: list.Add(text + text2); break; default: list.Add(text + text2); break; } if (source.Chance.HasValue) { list.Add("chance " + Number(source.Chance.Value * 100.0) + "%"); } if (source.Capacity.HasValue) { list.Add($"capacity {source.Capacity.Value}"); } if (source.RespawnSeconds.HasValue) { list.Add("respawn " + Number(source.RespawnSeconds.Value / 60.0) + " min"); } if (source.ConfiguredMinimumAmount.HasValue && source.ConfiguredMaximumAmount.HasValue) { string text5 = ((source.ConfiguredMinimumAmount.Value == source.ConfiguredMaximumAmount.Value) ? source.ConfiguredMinimumAmount.Value.ToString(CultureInfo.InvariantCulture) : $"{source.ConfiguredMinimumAmount.Value}-{source.ConfiguredMaximumAmount.Value}"); if (source.ConfiguredMinimumAmount.Value != source.MinimumAmount || source.ConfiguredMaximumAmount.Value != source.MaximumAmount) { list.Add("configured base " + text5 + text2); } list.Add((source.QuantitySemantics == WikiQuantitySemantics.ExactLoadedRange) ? "classification: guaranteed variable" : "classification: guaranteed fixed"); } if (source.LoadedResourceRate.HasValue && Math.Abs(source.LoadedResourceRate.Value - 1.0) > 0.0001) { list.Add("loaded resource rate " + Number(source.LoadedResourceRate.Value) + "x"); } if (!string.IsNullOrWhiteSpace(source.UnlockRequirement)) { list.Add("requires global key " + source.UnlockRequirement); } if (source.OnePerPlayer) { list.Add("one per player"); } if (!string.IsNullOrWhiteSpace(source.Method)) { list.Add("method: " + source.Method); } if (source.MinimumToolTier.HasValue) { list.Add($"minimum tool tier {source.MinimumToolTier.Value}"); } if (source.LevelScaled) { list.Add("quantity can scale with creature level"); } if (source.WorldScaled) { list.Add("world-rate scaled"); } if (variantCount > 1) { list.Add($"{variantCount} loaded prefab variants"); } list.Add("certainty: " + SourceCertaintyLabel(source.Certainty)); return string.Join(" | ", list); } private static IReadOnlyList CollapseSourceVariants(IEnumerable sources) { return (from @group in sources.GroupBy(SourceDisplayKey, StringComparer.Ordinal) select new SourceDisplayGroup(@group.First(), @group.Count())).ToArray(); } private static string SourceDisplayKey(WikiAcquisitionSource source) { return string.Join("\u001f", source.Kind.ToString(), source.Certainty.ToString(), source.Adapter, source.SourceName, source.SourceMod ?? string.Empty, source.OutputItemId, source.OutputName, source.MinimumAmount.ToString(CultureInfo.InvariantCulture), source.MaximumAmount.ToString(CultureInfo.InvariantCulture), NullableNumber(source.Chance), source.CostItemId ?? string.Empty, source.CostItemName ?? string.Empty, source.Price?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, source.UnlockRequirement ?? string.Empty, NullableNumber(source.ProductionIntervalSeconds), source.Capacity?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, NullableNumber(source.RespawnSeconds), source.OnePerPlayer.ToString(), source.LevelScaled.ToString(), source.WorldScaled.ToString(), source.Method ?? string.Empty, source.MinimumToolTier?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, source.QuantitySemantics.ToString(), source.ConfiguredMinimumAmount?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, source.ConfiguredMaximumAmount?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, NullableNumber(source.LoadedResourceRate)); } private static string NullableNumber(double? value) { if (!value.HasValue) { return string.Empty; } return value.Value.ToString("R", CultureInfo.InvariantCulture); } private static WikiSourceKind? SourceKindFilter(WikiQuery query) { if (!query.Filters.TryGetValue("sourceKind", out string value)) { return null; } if (!Enum.TryParse(value, ignoreCase: true, out var result)) { return null; } return result; } private static string SourceKindLabel(WikiSourceKind kind) { return kind switch { WikiSourceKind.CreatureDrop => "Creature drop", WikiSourceKind.Purchased => "Purchased", WikiSourceKind.PassivelyProduced => "Passively produced", WikiSourceKind.Harvested => "Harvested", WikiSourceKind.MinedOrDestroyed => "Mined or destroyed", _ => "Fishing", }; } private static string SourceCertaintyLabel(WikiSourceCertainty certainty) { return certainty switch { WikiSourceCertainty.Chance => "chance", WikiSourceCertainty.Conditional => "conditional", _ => "guaranteed", }; } private static WikiAnswer Station(WikiIndex index, WikiItem item) { IReadOnlyList recipes = index.RecipesForOutput(item.Id); IReadOnlyList readOnlyList = index.ConversionsForOutput(item.Id); if (recipes.Count == 0 && readOnlyList.Count == 0) { return new WikiAnswer(item.DisplayName, new string[1] { "No indexed crafting or input-processing method was found." }); } IEnumerable lines = recipes.Select((WikiRecipe recipe, int indexValue) => (recipes.Count != 1) ? $"Recipe {indexValue + 1}: {CraftAt(recipe, 1)}" : CraftAt(recipe, 1)).Concat(readOnlyList.Select((WikiConversion conversion) => "Process at: " + conversion.StationName + " (" + ProcessLabel(conversion.Kind) + ")")); return new WikiAnswer("How to make " + item.DisplayName, lines); } private WikiAnswer StationQuery(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (query.EntityTexts.Count == 0) { return new WikiAnswer("Missing item or station", new string[1] { "Please provide an item or crafting-station name." }); } string text = query.EntityTexts[0]; StationResolution stationResolution = ResolveStation(index, text, allowFuzzy: false, visibility); if (stationResolution.Status != ResolutionStatus.NotFound) { return StationResolutionAnswer(index, stationResolution, visibility); } ResolutionResult resolutionResult = resolver.Resolve(index, text, visibility.IsVisible); if (resolutionResult.Status == ResolutionStatus.Resolved && resolutionResult.Item != null) { return Station(index, resolutionResult.Item); } if (resolutionResult.Status == ResolutionStatus.Ambiguous) { return ResolutionAnswer(text, resolutionResult); } StationResolution stationResolution2 = ResolveStation(index, text, allowFuzzy: true, visibility); if (stationResolution2.Status != ResolutionStatus.NotFound) { return StationResolutionAnswer(index, stationResolution2, visibility); } return new WikiAnswer("Item or station not found", new string[1] { "No confident item or crafting-station match for '" + text + "'." }, null, new string[1] { "Try /wiki station forge or /wiki station iron sword." }); } private static WikiAnswer StationContents(WikiIndex index, string stationName, WikiVisibilitySnapshot visibility) { WikiRecipe[] array = (from @group in (from recipe in index.Recipes where string.Equals(recipe.StationName, stationName, StringComparison.CurrentCultureIgnoreCase) where visibility.IsVisible(recipe.OutputItemId) select recipe).GroupBy((WikiRecipe recipe) => $"{recipe.OutputItemId}\u001f{recipe.StationLevelForTargetQuality(1)}", StringComparer.OrdinalIgnoreCase) select @group.First()).OrderBy((WikiRecipe recipe) => recipe.OutputName, StringComparer.CurrentCultureIgnoreCase).ToArray(); WikiPiece[] array2 = (from @group in index.Pieces.Where((WikiPiece piece) => string.Equals(piece.StationName, stationName, StringComparison.CurrentCultureIgnoreCase)).Where(visibility.IsVisible).GroupBy((WikiPiece piece) => piece.Id, StringComparer.OrdinalIgnoreCase) select @group.First()).OrderBy((WikiPiece piece) => piece.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToArray(); WikiConversion[] array3 = (from @group in (from conversion in index.Conversions where string.Equals(conversion.StationName, stationName, StringComparison.CurrentCultureIgnoreCase) where visibility.IsVisible(conversion.OutputItemId) select conversion).GroupBy((WikiConversion conversion) => conversion.Id, StringComparer.OrdinalIgnoreCase) select @group.First()).OrderBy((WikiConversion conversion) => conversion.OutputName, StringComparer.CurrentCultureIgnoreCase).ToArray(); List list = new List(); if (array.Length != 0) { list.Add("Crafting:"); list.AddRange(array.Select((WikiRecipe recipe) => $"- {recipe.OutputName} (level {recipe.StationLevelForTargetQuality(1)})")); } if (array2.Length != 0) { list.Add("Building:"); list.AddRange(array2.Select((WikiPiece piece) => "- " + piece.DisplayName)); } if (array3.Length != 0) { list.Add("Processing:"); list.AddRange(array3.Select((WikiConversion conversion) => "- " + ConversionSummary(conversion, visibility))); } if (list.Count == 0) { list.Add("No indexed recipe, build piece, or processing conversion uses this station."); } return new WikiAnswer($"{stationName} uses ({array.Length + array2.Length + array3.Length})", list, (array.Length == 0) ? null : new string[1] { "Crafting levels shown are the minimum for quality 1." }); } private StationResolution ResolveStation(WikiIndex index, string text, bool allowFuzzy, WikiVisibilitySnapshot visibility) { bool wasQuoted; string query = TextNormalizer.Unquote(text, out wasQuoted); string normalized = TextNormalizer.Normalize(query); string[] source = (from name in (from recipe in index.Recipes where visibility.IsVisible(recipe.OutputItemId) select recipe.StationName).Concat(from piece in index.Pieces.Where(visibility.IsVisible) select piece.StationName).Concat(from conversion in index.Conversions where visibility.IsVisible(conversion.OutputItemId) select conversion.StationName) where !string.IsNullOrWhiteSpace(name) select (name)).Distinct(StringComparer.CurrentCultureIgnoreCase).OrderBy((string name) => name, StringComparer.CurrentCultureIgnoreCase).ToArray(); string[] array = source.Where((string name) => string.Equals(name, query, StringComparison.CurrentCultureIgnoreCase)).ToArray(); if (array.Length == 0) { array = source.Where((string name) => TextNormalizer.Normalize(name) == normalized).ToArray(); } if (array.Length != 0) { return new StationResolution((array.Length != 1) ? ResolutionStatus.Ambiguous : ResolutionStatus.Resolved, array, "exact station name"); } if (!allowFuzzy || wasQuoted || !options.FuzzyMatchingEnabled) { return new StationResolution(ResolutionStatus.NotFound, Array.Empty(), "no exact station match"); } var array2 = (from name in source select new { Name = name, Score = FuzzyMatcher.Similarity(query, name) } into candidate where candidate.Score >= options.FuzzyMatchThreshold orderby candidate.Score descending select candidate).ThenBy(candidate => candidate.Name, StringComparer.CurrentCultureIgnoreCase).Take(5).ToArray(); if (array2.Length == 0) { return new StationResolution(ResolutionStatus.NotFound, Array.Empty(), "no fuzzy station match"); } bool flag = array2.Length > 1 && array2[0].Score - array2[1].Score <= options.AmbiguityMargin; int status = (flag ? 1 : 0); IEnumerable names; if (!flag) { IEnumerable enumerable = new string[1] { array2[0].Name }; names = enumerable; } else { names = array2.Select(candidate => candidate.Name); } return new StationResolution((ResolutionStatus)status, names, flag ? "station matches are within the ambiguity margin" : "fuzzy station name"); } private static WikiAnswer StationResolutionAnswer(WikiIndex index, StationResolution resolution, WikiVisibilitySnapshot visibility) { if (resolution.Status == ResolutionStatus.Resolved) { return StationContents(index, resolution.Names[0], visibility); } return new WikiAnswer("Several possible stations", resolution.Names.Select((string name, int candidateIndex) => $"{candidateIndex + 1}. {name}"), null, new string[1] { "Try an exact station name, for example /wiki station \"" + resolution.Names[0] + "\"." }); } private WikiAnswer Compare(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (query.EntityTexts.Count != 2) { return new WikiAnswer("Comparison", new string[1] { "Provide exactly two item names." }); } ResolutionResult resolutionResult = resolver.Resolve(index, query.EntityTexts[0], visibility.IsVisible); if (resolutionResult.Status != ResolutionStatus.Resolved || resolutionResult.Item == null) { return ResolutionAnswer(query.EntityTexts[0], resolutionResult); } ResolutionResult resolutionResult2 = resolver.Resolve(index, query.EntityTexts[1], visibility.IsVisible); if (resolutionResult2.Status != ResolutionStatus.Resolved || resolutionResult2.Item == null) { return ResolutionAnswer(query.EntityTexts[1], resolutionResult2); } WikiItem item = resolutionResult.Item; WikiItem item2 = resolutionResult2.Item; int num = query.Quality ?? 1; int num2 = ClampQuality(item, num); int num3 = ClampQuality(item2, num); WikiQualityStats leftStats = item.StatsAt(num2); WikiQualityStats rightStats = item2.StatsAt(num3); if (query.Filters.TryGetValue("metric", out string value)) { return CompareByMetric(item, item2, leftStats, rightStats, num2, num3, num, value); } List list = new List(); List list2 = new List(); if (item.Kind == WikiItemKind.Weapon && item2.Kind == WikiItemKind.Weapon) { if (leftStats.Damage != null && rightStats.Damage != null) { list.Add(Pair("Listed total attack damage", leftStats.Damage.ListedCombatTotal, rightStats.Damage.ListedCombatTotal, item, item2)); foreach (DamageKind item3 in CombatDamageOrder.Where((DamageKind kind) => leftStats.Damage.HasPositive(kind) || rightStats.Damage.HasPositive(kind))) { list.Add(Pair(item3.ToString(), leftStats.Damage.Get(item3), rightStats.Damage.Get(item3), item, item2)); } } else { list2.Add("Loaded damage data was unavailable for one or both requested qualities, so no damage ranking is shown."); } AddPair(list, "Primary attack stamina", leftStats.PrimaryAttackStamina, rightStats.PrimaryAttackStamina, item, item2); AddPair(list, "Secondary attack stamina", leftStats.SecondaryAttackStamina, rightStats.SecondaryAttackStamina, item, item2); AddPair(list, "Durability", leftStats.Durability, rightStats.Durability, item, item2); AddPair(list, "Block", leftStats.Block, rightStats.Block, item, item2); AddPair(list, "Parry bonus", item.ParryBonus, item2.ParryBonus, item, item2, "x"); list.Add(Pair("Movement modifier %", item.MovementModifier * 100.0, item2.MovementModifier * 100.0, item, item2)); if (options.ShowComparisonAssumptions) { list2.Add("Listed damage excludes chop/pickaxe and does not model resistances, status effects, skill scaling, attack chains, or real DPS."); } } else if (item.Kind == WikiItemKind.Armor && item2.Kind == WikiItemKind.Armor) { AddPair(list, "Armor", leftStats.Armor, rightStats.Armor, item, item2); AddPair(list, "Durability", leftStats.Durability, rightStats.Durability, item, item2); list.Add(Pair("Movement modifier %", item.MovementModifier * 100.0, item2.MovementModifier * 100.0, item, item2)); list.Add(Pair("Weight", item.Weight, item2.Weight, item, item2)); } else if (item.Kind == WikiItemKind.Shield && item2.Kind == WikiItemKind.Shield) { AddPair(list, "Block", leftStats.Block, rightStats.Block, item, item2); AddPair(list, "Durability", leftStats.Durability, rightStats.Durability, item, item2); AddPair(list, "Parry bonus", item.ParryBonus, item2.ParryBonus, item, item2, "x"); list.Add(Pair("Movement modifier %", item.MovementModifier * 100.0, item2.MovementModifier * 100.0, item, item2)); list.Add(Pair("Weight", item.Weight, item2.Weight, item, item2)); } else if (item.Kind == WikiItemKind.Food && item2.Kind == WikiItemKind.Food) { AddPair(list, "Health", item.FoodHealth, item2.FoodHealth, item, item2); AddPair(list, "Stamina", item.FoodStamina, item2.FoodStamina, item, item2); AddPair(list, "Eitr", item.FoodEitr, item2.FoodEitr, item, item2); AddPair(list, "Duration seconds", item.FoodDurationSeconds, item2.FoodDurationSeconds, item, item2); AddPair(list, "Regeneration", item.FoodRegeneration, item2.FoodRegeneration, item, item2); } else { list.Add(Pair("Weight", item.Weight, item2.Weight, item, item2)); list.Add("Category: " + item.DisplayName + " " + item.Category + " | " + item2.DisplayName + " " + item2.Category); list2.Add("These items do not share a supported comparison profile, so no overall ranking is given."); } list.Add($"Quality assumption: {item.DisplayName} q{num2} | {item2.DisplayName} q{num3}"); if (num2 != num || num3 != num) { list2.Add("Requested quality was clamped independently to each item's maximum quality."); } return new WikiAnswer(item.DisplayName + " vs " + item2.DisplayName, list, list2); } private static WikiAnswer CompareByMetric(WikiItem left, WikiItem right, WikiQualityStats leftStats, WikiQualityStats rightStats, int leftQuality, int rightQuality, int requestedQuality, string metricText) { if (!ComparisonMetricCatalog.TryResolve(metricText, left, right, out ComparisonMetricDefinition metric, out string diagnostic) || metric == null) { return UnsupportedMetric(diagnostic); } if (!ComparisonMetricCatalog.TryValue(metric, left, leftStats, out var value) || !ComparisonMetricCatalog.TryValue(metric, right, rightStats, out var value2)) { return new WikiAnswer(left.DisplayName + " vs " + right.DisplayName, new string[1] { metric.Name + " is unavailable or inapplicable for one or both loaded items at the requested quality." }, null, new string[1] { "Supported metrics: " + ComparisonMetricCatalog.SupportedSummary + "." }); } int num = ComparisonMetricCatalog.Compare(metric, value, value2); string text = ((num == 0) ? ("Result: tied on " + metric.Name.ToLowerInvariant() + ".") : ("Result: " + ((num > 0) ? left.DisplayName : right.DisplayName) + " ranks higher on this selected metric by " + Number(Math.Abs(value - value2)) + metric.Suffix + ".")); List list = new List { metric.Caveat }; if (leftQuality != requestedQuality || rightQuality != requestedQuality) { list.Add("Requested quality was clamped independently to each item's maximum quality."); } return new WikiAnswer(left.DisplayName + " vs " + right.DisplayName, new string[4] { "Selected metric: " + metric.Name + " (" + metric.DirectionLabel + ").", Pair(metric.Name, value, value2, left, right, metric.Suffix), text, $"Quality assumption: {left.DisplayName} q{leftQuality} | {right.DisplayName} q{rightQuality}" }, list); } private WikiAnswer ExplainComparison(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (query.EntityTexts.Count != 2) { return new WikiAnswer("Why comparison", new string[1] { "Provide exactly two item names." }); } ResolutionResult resolutionResult = resolver.Resolve(index, query.EntityTexts[0], visibility.IsVisible); if (resolutionResult.Status != ResolutionStatus.Resolved || resolutionResult.Item == null) { return ResolutionAnswer(query.EntityTexts[0], resolutionResult); } ResolutionResult resolutionResult2 = resolver.Resolve(index, query.EntityTexts[1], visibility.IsVisible); if (resolutionResult2.Status != ResolutionStatus.Resolved || resolutionResult2.Item == null) { return ResolutionAnswer(query.EntityTexts[1], resolutionResult2); } WikiItem item = resolutionResult.Item; WikiItem item2 = resolutionResult2.Item; int num = query.Quality ?? 1; int num2 = ClampQuality(item, num); int num3 = ClampQuality(item2, num); if (!ComparisonMetricCatalog.TryResolve(query.Filters.TryGetValue("metric", out string value) ? value : "damage", item, item2, out ComparisonMetricDefinition metric, out string diagnostic) || metric == null) { return UnsupportedMetric(diagnostic); } if (!ComparisonMetricCatalog.TryValue(metric, item, item.StatsAt(num2), out var value2) || !ComparisonMetricCatalog.TryValue(metric, item2, item2.StatsAt(num3), out var value3)) { return new WikiAnswer("Comparison explanation unavailable", new string[1] { metric.Name + " is unavailable or inapplicable for one or both loaded items." }); } int num4 = ComparisonMetricCatalog.Compare(metric, value2, value3); string text = ((num4 > 0) ? ("The loaded values support the premise: " + item.DisplayName + " ranks higher on this metric by " + Number(Math.Abs(value2 - value3)) + metric.Suffix + ".") : ((num4 >= 0) ? ("The loaded values do not support a stronger claim: both items are tied on " + metric.Name.ToLowerInvariant() + ".") : ("The loaded values do not support the premise: " + item2.DisplayName + " ranks higher on this metric by " + Number(Math.Abs(value2 - value3)) + metric.Suffix + "."))); List list = new List { metric.Caveat }; if (num2 != num || num3 != num) { list.Add("Requested quality was clamped independently to each item's maximum quality."); } return new WikiAnswer("Why " + item.DisplayName + " versus " + item2.DisplayName, new string[5] { "Selected metric: " + metric.Name + " (" + metric.DirectionLabel + ").", $"{item.DisplayName} q{num2}: {Number(value2)}{metric.Suffix}", $"{item2.DisplayName} q{num3}: {Number(value3)}{metric.Suffix}", text, "Explanation source: deterministic loaded runtime fields only." }, list); } private WikiAnswer RankCategory(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (!query.Filters.TryGetValue("category", out string value) || !query.Filters.TryGetValue("metric", out string value2)) { return new WikiAnswer("Category comparison", new string[1] { "Provide a supported category and explicit metric." }); } WikiItem[] array = CategoryCandidates(index, value).Where(visibility.IsVisible).ToArray(); if (array.Length == 0) { return new WikiAnswer("Unsupported comparison category", new string[1] { "No supported loaded category matched '" + value + "'." }, null, new string[1] { "Try foods, shields, armor, weapons, swords, axes, bows, spears, clubs, knives, or polearms." }); } if (!ComparisonMetricCatalog.TryResolve(value2, array[0], array[0], out ComparisonMetricDefinition metric, out string diagnostic) || metric == null) { return UnsupportedMetric(diagnostic); } int requestedQuality = query.Quality ?? 1; var array2 = (from anon in array.Select((WikiItem item) => new { Item = item, Quality = ClampQuality(item, requestedQuality) }).Select(anon => { double value3; bool available = ComparisonMetricCatalog.TryValue(metric, anon.Item, anon.Item.StatsAt(anon.Quality), out value3); return new { Item = anon.Item, Quality = anon.Quality, Available = available, Value = value3 }; }) where anon.Available select anon).ToArray(); if (array2.Length == 0) { return new WikiAnswer(value + " by " + metric.Name, new string[1] { "The selected metric is unavailable or inapplicable for every loaded item in this category." }); } var source = (metric.HigherRanksFirst ? array2.OrderByDescending(anon => anon.Value).ThenBy(anon => anon.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase) : array2.OrderBy(anon => anon.Value).ThenBy(anon => anon.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase)); int count = Math.Max(1, options.MaximumAlternativeResults); var array3 = source.Take(count).ToArray(); List list = new List(); list.Add($"Metric: {metric.Name}; {metric.DirectionLabel}. Showing {array3.Length} of {array2.Length} loaded items with an available value."); list.Add(metric.Caveat); List list2 = list; if (array3.Any(anon => anon.Quality != requestedQuality)) { list2.Add("Requested quality was clamped independently to each item's loaded maximum quality."); } return new WikiAnswer("Top " + value + " by " + metric.Name, array3.Select((anon, int rank) => $"{rank + 1}. {Label(anon.Item)} q{anon.Quality}: {Number(anon.Value)}{metric.Suffix}"), list2); } private static IEnumerable CategoryCandidates(WikiIndex index, string categoryText) { string text = TextNormalizer.Normalize(categoryText); switch (text) { case "foods": case "food": return index.Items.Values.Where((WikiItem item) => item.Kind == WikiItemKind.Food); case "shield": case "shields": return index.Items.Values.Where((WikiItem item) => item.Kind == WikiItemKind.Shield); case "armor": case "armour": return index.Items.Values.Where((WikiItem item) => item.Kind == WikiItemKind.Armor); case "weapon": case "weapons": return index.Items.Values.Where((WikiItem item) => item.Kind == WikiItemKind.Weapon); default: { string category = text.TrimEnd(new char[1] { 's' }); if (category == "mace") { category = "club"; } bool flag; switch (category) { case "knife": case "spear": case "sword": case "axe": case "bow": case "club": case "polearm": flag = true; break; default: flag = false; break; } if (!flag) { return Array.Empty(); } return index.Items.Values.Where((WikiItem item) => item.Kind == WikiItemKind.Weapon && CategoryMatches(item.Category, category)); } } } private WikiAnswer Stronger(WikiIndex index, WikiItem baseline, WikiQuery query, WikiVisibilitySnapshot visibility) { string value; bool flag = query.Filters.TryGetValue("metric", out value); if (!flag && baseline.Kind != WikiItemKind.Weapon) { return new WikiAnswer("Stronger than " + baseline.DisplayName, new string[1] { "Unqualified stronger-than is defined only for weapons; select a supported metric for other item profiles." }); } int requestedQuality = query.Quality ?? 1; int num = ClampQuality(baseline, requestedQuality); if (!ComparisonMetricCatalog.TryResolve(flag ? value : "damage", baseline, baseline, out ComparisonMetricDefinition metric, out string diagnostic) || metric == null) { return UnsupportedMetric(diagnostic); } if (!ComparisonMetricCatalog.TryValue(metric, baseline, baseline.StatsAt(num), out var baselineValue)) { return new WikiAnswer("Alternatives to " + baseline.DisplayName, new string[1] { metric.Name + " is unavailable or inapplicable for the loaded baseline item." }); } var array = (from anon in (from item in index.Items.Values.Where(visibility.IsVisible) where !item.Id.Equals(baseline.Id, StringComparison.OrdinalIgnoreCase) where ComparableAlternative(baseline, item) select new { Item = item, Quality = ClampQuality(item, requestedQuality) }).Select(anon => { double value2; bool available = ComparisonMetricCatalog.TryValue(metric, anon.Item, anon.Item.StatsAt(anon.Quality), out value2); return new { Item = anon.Item, Quality = anon.Quality, Available = available, Value = value2 }; }) where anon.Available select new { Item = anon.Item, Quality = anon.Quality, Value = anon.Value, Improvement = ComparisonMetricCatalog.Improvement(metric, anon.Value, baselineValue) } into anon where anon.Improvement > 0.0 orderby anon.Improvement select anon).ThenBy(anon => anon.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).Take(Math.Max(1, options.MaximumAlternativeResults)).ToArray(); string[] lines = ((array.Length != 0) ? array.Select(anon => $"- {Label(anon.Item)} q{anon.Quality}: {Number(anon.Value)}{metric.Suffix} (improves by {Number(anon.Improvement)}{metric.Suffix})").ToArray() : new string[1] { "No comparable indexed item strictly improves on " + Number(baselineValue) + metric.Suffix + " " + metric.Name.ToLowerInvariant() + "." }); List list = new List(); list.Add(string.Format("Metric: {0} at requested quality {1}; {2}. {3}", metric.Name, requestedQuality, metric.DirectionLabel, (baseline.Kind == WikiItemKind.Weapon) ? "Weapon alternatives stay in the same category." : "Alternatives stay in the same item profile.")); list.Add(metric.Caveat); List list2 = list; if (num != requestedQuality || array.Any(anon => anon.Quality != requestedQuality)) { list2.Add("Requested quality was clamped independently to loaded maximum quality."); } if (!flag && requestedQuality == 1) { list2.Add("Default stronger metric: quality-1 listed combat damage in the same weapon category."); } return new WikiAnswer("Alternatives by " + metric.Name + " for " + baseline.DisplayName, lines, list2); } private static bool ComparableAlternative(WikiItem baseline, WikiItem candidate) { if (baseline.Kind == candidate.Kind) { if (baseline.Kind == WikiItemKind.Weapon) { return string.Equals(baseline.Category, candidate.Category, StringComparison.OrdinalIgnoreCase); } return true; } return false; } private static WikiAnswer UnsupportedMetric(string diagnostic) { return new WikiAnswer("Unsupported comparison metric", new string[1] { diagnostic }, null, new string[1] { "Try: by slash, by stamina, by block, by armor, or by weight. Supported metrics: " + ComparisonMetricCatalog.SupportedSummary + "." }); } private WikiAnswer Find(WikiIndex index, WikiQuery query, WikiVisibilitySnapshot visibility) { if (!query.Filters.TryGetValue("expression", out string value) || string.IsNullOrWhiteSpace(value)) { return new WikiAnswer("Find", new string[1] { "Provide a category or supported filter." }); } FilterExpression parsed = FilterExpression.Parse(value); if (!parsed.IsSupported) { return new WikiAnswer("Unsupported filter", new string[1] { parsed.Diagnostic }, null, new string[3] { "Try: /wiki find swords", "/wiki foods above 70 health", "/wiki find weapons with frost" }); } IEnumerable source = index.Items.Values.Where(visibility.IsVisible); if (parsed.Kind.HasValue) { source = source.Where((WikiItem item) => item.Kind == parsed.Kind.Value); } string category = parsed.Category; if (!string.IsNullOrEmpty(category)) { source = source.Where((WikiItem item) => CategoryMatches(item.Category, category)); } if (parsed.Damage.HasValue) { source = source.Where((WikiItem item) => item.StatsAt(1).Damage?.HasPositive(parsed.Damage.Value) ?? false); } if (parsed.NumericField != null && parsed.NumericValue.HasValue && parsed.Operator != null) { source = source.Where((WikiItem item) => NumericFilter(GetNumeric(item, parsed.NumericField), parsed.Operator, parsed.NumericValue.Value)); } WikiItem[] array = source.OrderBy((WikiItem item) => item.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToArray(); if (array.Length == 0) { return new WikiAnswer("Find results", new string[1] { "No indexed items matched the supported filter." }); } return new WikiAnswer($"Find results ({array.Length})", array.Select((WikiItem item) => (parsed.NumericField != null) ? ("- " + Label(item) + " | " + item.Category + " | " + NumericFieldLabel(parsed.NumericField, item) + " " + Number(GetNumeric(item, parsed.NumericField).Value)) : ("- " + Label(item) + " | " + item.Category))); } private WikiAnswer Diagnostics(WikiIndex index, WikiVisibilitySnapshot visibility) { WikiItem[] array = index.Items.Values.Where(visibility.IsVisible).ToArray(); WikiPiece[] array2 = index.Pieces.Where(visibility.IsVisible).ToArray(); IGrouping[] array3 = (from @group in array.GroupBy((WikiItem item) => item.DisplayName, StringComparer.CurrentCultureIgnoreCase) where @group.Count() > 1 select @group).OrderBy, string>((IGrouping group) => group.Key, StringComparer.CurrentCultureIgnoreCase).ToArray(); int num = CountDanglingEdges(index); int num2 = index.Recipes.Count((WikiRecipe recipe) => recipe.Requirements.Count == 0); int num3 = index.Conversions.Count((WikiConversion conversion) => conversion.Requirements.Count == 0); int num4 = array.Count((WikiItem item) => !string.IsNullOrWhiteSpace(item.SourceMod)); List list = new List(); list.Add($"Visibility: {visibility.Policy} | items {array.Length}/{index.Items.Count} | pieces {array2.Length}/{index.Pieces.Count}."); list.Add($"Build isolation: {index.SkippedEntries} skipped entries | {index.UnavailableStatistics} unavailable optional statistics."); list.Add($"Retained issue records: {index.Diagnostics.Count} | omitted after hard bound: {index.OmittedDiagnostics}."); list.Add(string.Format("Relationship integrity: {0} dangling item reference{1} | recipes with no listed requirements: {2} | conversions with no listed inputs: {3}.", num, (num == 1) ? string.Empty : "s", num2, num3)); list.Add(string.Format("Localized-name ambiguity: {0} visible duplicate group{1}.", array3.Length, (array3.Length == 1) ? string.Empty : "s")); list.Add($"Item mod attribution: {num4}/{array.Length} visible items attributed; unattributed can mean vanilla or startup detection disabled."); list.Add($"Knowledge/context isolation: {visibility.EvaluationErrors} visibility evaluation errors in this query snapshot."); List list2 = list; list2.AddRange(from @group in array3.Take(5) select $"- Duplicate name: {@group.Key} ({@group.Count()} visible item records)"); if (array3.Length > 5) { list2.Add($"{array3.Length - 5} additional duplicate-name groups are available through paginated diagnostics in a future release; this summary is bounded to five."); } return new WikiAnswer("Diagnostics", list2, new string[1] { "Counts describe the immutable loaded index and current query visibility. Use /wiki unsupported for bounded issue details and /wiki mods for attribution groups." }); } private WikiAnswer ModReport(WikiIndex index, WikiVisibilitySnapshot visibility) { WikiItem[] source = index.Items.Values.Where(visibility.IsVisible).ToArray(); WikiAcquisitionSource[] source2 = index.Sources.Where((WikiAcquisitionSource wikiAcquisitionSource) => visibility.IsVisible(wikiAcquisitionSource.OutputItemId)).ToArray(); var array = (from @group in source.GroupBy((WikiItem item) => (!string.IsNullOrWhiteSpace(item.SourceMod)) ? item.SourceMod : "Unattributed (vanilla or detection disabled)", StringComparer.CurrentCultureIgnoreCase) select new { Name = @group.Key, Items = @group.Count() } into @group orderby @group.Items descending select @group).ThenBy(group => group.Name, StringComparer.CurrentCultureIgnoreCase).ToArray(); Dictionary sourceGroups = source2.Where((WikiAcquisitionSource wikiAcquisitionSource) => !string.IsNullOrWhiteSpace(wikiAcquisitionSource.SourceMod)).GroupBy((WikiAcquisitionSource wikiAcquisitionSource) => wikiAcquisitionSource.SourceMod, StringComparer.CurrentCultureIgnoreCase).ToDictionary, string, int>((IGrouping group) => group.Key, (IGrouping group) => group.Count(), StringComparer.CurrentCultureIgnoreCase); var array2 = array.Take(20).ToArray(); int value; List list = array2.Select(group => string.Format("- {0}: {1} item{2}{3}", group.Name, group.Items, (group.Items == 1) ? string.Empty : "s", sourceGroups.TryGetValue(group.Name, out value) ? string.Format(" | {0} direct source edge{1}", value, (value == 1) ? string.Empty : "s") : string.Empty)).ToList(); if (array.Length == 0) { list.Add("No visible item records are available under the current policy."); } if (array.Length > array2.Length) { list.Add($"{array.Length - array2.Length} additional attribution groups were omitted by the {20}-row bound."); } if (!source.Any((WikiItem item) => !string.IsNullOrWhiteSpace(item.SourceMod))) { list.Add("No item source-mod labels are available. EnableModSourceDetection must be enabled before startup for best-effort Jotunn attribution."); } return new WikiAnswer($"Indexed mods ({array.Length} groups)", list, new string[1] { $"Visibility: {visibility.Policy}. Attribution is best effort and does not change indexed facts." }, null, isPaginated: true); } private WikiAnswer UnsupportedReport(WikiIndex index, WikiVisibilitySnapshot visibility) { IGrouping[] array = (from @group in index.Diagnostics.Where((WikiDiagnosticIssue issue) => issue.Kind == WikiDiagnosticKind.SkippedEntry).GroupBy((WikiDiagnosticIssue issue) => issue.Category, StringComparer.CurrentCultureIgnoreCase) orderby @group.Count() descending select @group).ThenBy, string>((IGrouping group) => group.Key, StringComparer.CurrentCultureIgnoreCase).ToArray(); WikiDiagnosticIssue[] array2 = index.Diagnostics.Where((WikiDiagnosticIssue issue) => !visibility.IsRestricted || (issue.RelatedItemId != null && visibility.IsVisible(issue.RelatedItemId))).Take(20).ToArray(); List list = new List { $"Full-index isolation totals: {index.SkippedEntries} skipped entries | {index.UnavailableStatistics} unavailable optional statistics.", $"Retained issue records: {index.Diagnostics.Count} | omitted after 256: {index.OmittedDiagnostics}." }; if (array.Length != 0) { list.Add("Skipped categories: " + string.Join(", ", from @group in array.Take(10) select $"{@group.Key} {@group.Count()}") + ((array.Length > 10) ? $"; {array.Length - 10} more categories" : string.Empty) + "."); } WikiDiagnosticIssue[] array3 = array2; foreach (WikiDiagnosticIssue wikiDiagnosticIssue in array3) { if (wikiDiagnosticIssue.Kind == WikiDiagnosticKind.UnavailableStatistic && wikiDiagnosticIssue.RelatedItemId != null && index.Items.TryGetValue(wikiDiagnosticIssue.RelatedItemId, out WikiItem value)) { list.Add("- Optional statistic '" + wikiDiagnosticIssue.Category + "' unavailable for " + Label(value) + " (" + wikiDiagnosticIssue.ReasonCode + ")."); } else if (!visibility.IsRestricted) { list.Add("- Skipped " + wikiDiagnosticIssue.Category + ": " + wikiDiagnosticIssue.EntityId + " (" + wikiDiagnosticIssue.ReasonCode + ")."); } } if (visibility.IsRestricted && index.Diagnostics.Any((WikiDiagnosticIssue issue) => issue.RelatedItemId == null)) { list.Add("Unassociated skipped-prefab identifiers are not listed under a character-knowledge policy."); } if (index.Diagnostics.Count > array2.Length) { list.Add($"Issue detail output is bounded to {20} eligible records."); } list.Add("Intentionally unsupported: chance/weighted/multi-output/zero-minimum environmental tables; fish extra drops and bait probabilities; containers; locations; biome inference; random pickables; procedural loot."); list.Add("Intentionally unsupported: DPS, resistance-adjusted damage, attack timing, skill simulation, composite best-item scores, scene-distance station scans, inventory mutation, and progression mutation."); return new WikiAnswer("Unsupported and isolated content", list, new string[1] { $"Visibility: {visibility.Policy}. Aggregate full-index counts are safe; hidden entity identifiers remain suppressed." }, null, isPaginated: true); } private static int CountDanglingEdges(WikiIndex index) { return index.Recipes.Count((WikiRecipe recipe) => !index.Items.ContainsKey(recipe.OutputItemId)) + index.Recipes.Sum((WikiRecipe recipe) => recipe.Requirements.Count((WikiRequirement requirement) => !index.Items.ContainsKey(requirement.ItemId))) + index.Pieces.Sum((WikiPiece piece) => piece.Requirements.Count((WikiPieceRequirement requirement) => !index.Items.ContainsKey(requirement.ItemId))) + (index.Conversions.Count((WikiConversion conversion) => !index.Items.ContainsKey(conversion.OutputItemId)) + index.Conversions.Sum((WikiConversion conversion) => conversion.Requirements.Count((WikiConversionRequirement requirement) => !index.Items.ContainsKey(requirement.ItemId)))) + (index.Sources.Count((WikiAcquisitionSource source) => !index.Items.ContainsKey(source.OutputItemId)) + index.Sources.Count((WikiAcquisitionSource source) => source.CostItemId != null && !index.Items.ContainsKey(source.CostItemId))); } private WikiAnswer ResolutionAnswer(string query, ResolutionResult resolution) { if (resolution.Status == ResolutionStatus.Ambiguous) { return new WikiAnswer("Several possible matches", resolution.Candidates.Select((ResolutionCandidate candidate, int index) => $"{index + 1}. {Label(candidate.Item)}"), null, new string[1] { "Try an exact quoted name, for example /wiki info \"" + resolution.Candidates[0].Item.DisplayName + "\"." }); } return new WikiAnswer("Item not found", new string[2] { "No confident match for '" + query + "'.", resolution.Diagnostic }, null, new string[1] { "Check spelling or use /wiki find ." }); } private void AddIdentity(ICollection lines, WikiItem item) { if (options.ShowInternalNames) { lines.Add("Internal name: " + item.InternalName); } if (options.ShowSourceMod && !string.IsNullOrWhiteSpace(item.SourceMod)) { lines.Add("Source: " + item.SourceMod); } } private string Label(WikiItem item) { if (!options.ShowSourceMod || string.IsNullOrWhiteSpace(item.SourceMod)) { return item.DisplayName; } return item.DisplayName + " [" + item.SourceMod + "]"; } private static int ClampQuality(WikiItem item, int requested) { return Math.Max(1, Math.Min(item.MaxQuality, requested)); } private static string Number(double value) { return value.ToString("0.##", CultureInfo.InvariantCulture); } private static string DamageSummary(WikiDamageProfile damage) { return string.Join(", ", from kind in CombatDamageOrder.Where(damage.HasPositive) select kind.ToString().ToLowerInvariant() + " " + Number(damage.Get(kind))); } private static void AddNullable(ICollection lines, string label, double? value, string suffix = "") { if (value.HasValue) { lines.Add(label + ": " + Number(value.Value) + suffix); } } private static void AddPair(ICollection lines, string label, double? leftValue, double? rightValue, WikiItem left, WikiItem right, string suffix = "") { if (leftValue.HasValue && rightValue.HasValue) { lines.Add(Pair(label, leftValue.Value, rightValue.Value, left, right, suffix)); } } private static string Pair(string label, double leftValue, double rightValue, WikiItem left, WikiItem right, string suffix = "") { return label + ": " + left.DisplayName + " " + Number(leftValue) + suffix + " | " + right.DisplayName + " " + Number(rightValue) + suffix; } private static string CraftAt(WikiRecipe recipe, int targetQuality) { if (recipe.StationName != null) { return $"Craft at: {recipe.StationName} level {recipe.StationLevelForTargetQuality(targetQuality)}"; } return "No station required."; } private static string StationText(string? station, int level) { if (station != null) { return $"{station} level {level}"; } return "no station"; } private static string Requirements(IEnumerable requirements, int targetQuality, bool requireOnlyOne, WikiVisibilitySnapshot visibility) { string[] array = (from requirement in requirements select new { ItemName = visibility.ItemName(requirement.ItemId, requirement.ItemName), Amount = requirement.AmountForTargetQuality(targetQuality) } into value where value.Amount > 0 select $"{value.Amount} {value.ItemName}").ToArray(); if (array.Length == 0) { return "none listed"; } return string.Join(requireOnlyOne ? " OR " : ", ", array); } private static string ConversionRequirements(WikiConversion conversion, WikiVisibilitySnapshot visibility) { WikiConversionRequirement[] source = conversion.Requirements.Where((WikiConversionRequirement requirement) => !string.Equals(requirement.Role, "Fuel", StringComparison.OrdinalIgnoreCase)).ToArray(); WikiConversionRequirement[] array = conversion.Requirements.Where((WikiConversionRequirement requirement) => string.Equals(requirement.Role, "Fuel", StringComparison.OrdinalIgnoreCase)).ToArray(); string text = string.Join(conversion.RequireOnlyOneIngredient ? " OR " : ", ", source.Select((WikiConversionRequirement requirement) => ConversionRequirementText(requirement, visibility))); List list = new List { "Input: " + (string.IsNullOrWhiteSpace(text) ? "none listed" : text) }; if (array.Length != 0) { list.Add("Fuel: " + string.Join(", ", array.Select((WikiConversionRequirement requirement) => ConversionRequirementText(requirement, visibility)))); } return string.Join(" | ", list); } private static string ConversionRequirementText(WikiConversionRequirement requirement, WikiVisibilitySnapshot visibility) { string text = visibility.ItemName(requirement.ItemId, requirement.ItemName); if (!requirement.Amount.HasValue) { return text + " (amount varies)"; } return Number(requirement.Amount.Value) + " " + text; } private static string ConversionUse(WikiConversion conversion, string materialId) { WikiConversionRequirement wikiConversionRequirement = conversion.Requirements.First((WikiConversionRequirement value) => value.ItemId.Equals(materialId, StringComparison.OrdinalIgnoreCase)); string text = (wikiConversionRequirement.Amount.HasValue ? Number(wikiConversionRequirement.Amount.Value) : "variable amount"); return text + " " + wikiConversionRequirement.ItemName + " (" + wikiConversionRequirement.Role.ToLowerInvariant() + ")"; } private static string ConversionSummary(WikiConversion conversion, WikiVisibilitySnapshot visibility) { return $"{ConversionRequirements(conversion, visibility)} -> {conversion.OutputAmount} {visibility.ItemName(conversion.OutputItemId, conversion.OutputName)}"; } private static string ProcessLabel(WikiProcessKind kind) { return kind switch { WikiProcessKind.Cooking => "cooking", WikiProcessKind.Fermenting => "fermenting", WikiProcessKind.Incinerating => "incinerating", _ => "processing", }; } private static int RequirementAmount(WikiRecipe recipe, string materialId, int targetQuality) { return recipe.Requirements.First((WikiRequirement requirement) => requirement.ItemId.Equals(materialId, StringComparison.OrdinalIgnoreCase)).AmountForTargetQuality(targetQuality); } private static bool CategoryMatches(string itemCategory, string filterCategory) { string text = TextNormalizer.Normalize(itemCategory); string text2 = TextNormalizer.Normalize(filterCategory); if (text2 == "mace") { text2 = "club"; } if (text2 == "axes") { text2 = "axe"; } if (!(text == text2)) { return text.TrimEnd(new char[1] { 's' }) == text2.TrimEnd(new char[1] { 's' }); } return true; } private static double? GetNumeric(WikiItem item, string field) { WikiQualityStats wikiQualityStats = item.StatsAt(1); switch (field) { case "health": return item.FoodHealth; case "stamina": if (item.Kind != WikiItemKind.Food) { return wikiQualityStats.PrimaryAttackStamina; } return item.FoodStamina; case "primary stamina": return wikiQualityStats.PrimaryAttackStamina; case "secondary stamina": return wikiQualityStats.SecondaryAttackStamina; case "eitr": return item.FoodEitr; case "duration": return item.FoodDurationSeconds; case "regeneration": return item.FoodRegeneration; case "armor": return wikiQualityStats.Armor; case "block": return wikiQualityStats.Block; case "durability": return wikiQualityStats.Durability; case "weight": return item.Weight; case "movement": return item.MovementModifier * 100.0; case "damage": if (item.Kind != WikiItemKind.Weapon) { return null; } return wikiQualityStats.Damage?.ListedCombatTotal; case "blunt": return WeaponDamage(item, wikiQualityStats, DamageKind.Blunt); case "slash": return WeaponDamage(item, wikiQualityStats, DamageKind.Slash); case "pierce": return WeaponDamage(item, wikiQualityStats, DamageKind.Pierce); case "fire": return WeaponDamage(item, wikiQualityStats, DamageKind.Fire); case "frost": return WeaponDamage(item, wikiQualityStats, DamageKind.Frost); case "lightning": return WeaponDamage(item, wikiQualityStats, DamageKind.Lightning); case "poison": return WeaponDamage(item, wikiQualityStats, DamageKind.Poison); case "spirit": return WeaponDamage(item, wikiQualityStats, DamageKind.Spirit); default: return null; } } private static double? WeaponDamage(WikiItem item, WikiQualityStats stats, DamageKind kind) { if (item.Kind != WikiItemKind.Weapon || stats.Damage == null) { return null; } return stats.Damage.Get(kind); } private static string NumericFieldLabel(string field, WikiItem item) { switch (field) { case "stamina": if (item.Kind != WikiItemKind.Food) { return "Primary attack stamina"; } return "Food stamina"; case "primary stamina": return "Primary attack stamina"; case "secondary stamina": return "Secondary attack stamina"; case "damage": return "Listed damage"; case "movement": return "Movement modifier %"; default: return char.ToUpperInvariant(field[0]) + field.Substring(1); } } private static bool NumericFilter(double? actual, string operation, double target) { if (!actual.HasValue || double.IsNaN(actual.Value) || double.IsInfinity(actual.Value)) { return false; } return operation switch { "above" => actual.Value > target, "below" => actual.Value < target, "at least" => actual.Value >= target, "at most" => actual.Value <= target, _ => false, }; } } public enum WikiKnowledgePolicy { AllLoaded, KnownItems, KnownRecipes, KnownToCharacter } public sealed class WikiVisibilitySnapshot { private readonly IReadOnlyCollection visibleItemIds; private readonly HashSet? visibleLookup; private readonly HashSet? visiblePieceLookup; public static WikiVisibilitySnapshot AllLoaded { get; } = new WikiVisibilitySnapshot(WikiKnowledgePolicy.AllLoaded, Array.Empty(), characterStateAvailable: true); public WikiKnowledgePolicy Policy { get; } public bool CharacterStateAvailable { get; } public int EvaluationErrors { get; } public bool IsRestricted => Policy != WikiKnowledgePolicy.AllLoaded; public IReadOnlyCollection VisibleItemIds => visibleItemIds; public string Description => Policy switch { WikiKnowledgePolicy.KnownItems => "items discovered by the current character", WikiKnowledgePolicy.KnownRecipes => "recipe outputs unlocked by the current character", WikiKnowledgePolicy.KnownToCharacter => "discovered items and unlocked recipe outputs for the current character", _ => "all content in the immutable loaded index", }; public WikiVisibilitySnapshot(WikiKnowledgePolicy policy, IEnumerable? visibleItemIds, bool characterStateAvailable, int evaluationErrors = 0, IEnumerable? visiblePieceIds = null) { Policy = policy; CharacterStateAvailable = policy == WikiKnowledgePolicy.AllLoaded || characterStateAvailable; EvaluationErrors = Math.Max(0, evaluationErrors); string[] array = (visibleItemIds ?? Array.Empty()).Where((string value) => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); this.visibleItemIds = Array.AsReadOnly(array); visibleLookup = ((policy == WikiKnowledgePolicy.AllLoaded) ? null : new HashSet(array, StringComparer.OrdinalIgnoreCase)); visiblePieceLookup = ((policy == WikiKnowledgePolicy.AllLoaded) ? null : new HashSet((visiblePieceIds ?? Array.Empty()).Where((string value) => !string.IsNullOrWhiteSpace(value)), StringComparer.OrdinalIgnoreCase)); } public bool IsVisible(WikiItem item) { if (item == null) { throw new ArgumentNullException("item"); } return IsVisible(item.Id); } public bool IsVisible(string itemId) { if (visibleLookup != null) { if (!string.IsNullOrWhiteSpace(itemId)) { return visibleLookup.Contains(itemId); } return false; } return true; } public bool IsVisible(WikiPiece piece) { if (piece == null) { throw new ArgumentNullException("piece"); } if (visiblePieceLookup != null) { return visiblePieceLookup.Contains(piece.Id); } return true; } public string ItemName(string itemId, string loadedName) { if (!IsVisible(itemId)) { return "[undiscovered item]"; } return loadedName; } } } namespace MimirsWell.Core.Resolution { public sealed class EntityResolverOptions { public bool FuzzyMatchingEnabled { get; set; } = true; public double FuzzyMatchThreshold { get; set; } = 0.82; public double AmbiguityMargin { get; set; } = 0.05; public int MaximumCandidates { get; set; } = 5; } public sealed class EntityResolver { private readonly EntityResolverOptions options; public EntityResolver(EntityResolverOptions options) { this.options = options ?? throw new ArgumentNullException("options"); } public ResolutionResult Resolve(WikiIndex index, string text, Func? visibility = null) { if (index == null) { throw new ArgumentNullException("index"); } bool wasQuoted; string query = TextNormalizer.Unquote(text, out wasQuoted); if (string.IsNullOrWhiteSpace(query)) { return new ResolutionResult(ResolutionStatus.NotFound, null, "Entity text is blank."); } WikiItem[] array = index.Items.Values.Where(visibility ?? ((Func)((WikiItem _) => true))).ToArray(); List list = ExactStage(array, query, (WikiItem item) => item.DisplayName, "exact localized name"); if (list.Count > 0) { return FinishExact(list, "localized display name"); } list = ExactStage(array, query, (WikiItem item) => item.InternalName, "exact internal name"); if (list.Count > 0) { return FinishExact(list, "internal name"); } list = (from item in array where item.Aliases.Any((string alias) => string.Equals(alias, query, StringComparison.OrdinalIgnoreCase)) select new ResolutionCandidate(item, 0.99, "exact alias")).OrderBy((ResolutionCandidate candidate) => candidate.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToList(); if (list.Count > 0) { return FinishExact(list, "configured alias"); } string normalized = TextNormalizer.Normalize(query); list = (from item in array where Names(item).Any((string name) => TextNormalizer.Normalize(name) == normalized) select new ResolutionCandidate(item, 0.97, "normalized name")).OrderBy((ResolutionCandidate candidate) => candidate.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToList(); if (list.Count > 0) { return FinishExact(list, "normalized name"); } if (wasQuoted) { return new ResolutionResult(ResolutionStatus.NotFound, null, "Quoted names require an exact localized, internal, alias, or normalized match."); } IReadOnlyList queryTokens = TextNormalizer.Tokens(query); List list2 = (from item in array where Names(item).Any((string name) => ContainsEveryToken(name, queryTokens)) select new ResolutionCandidate(item, 0.94, "normalized token match")).OrderBy((ResolutionCandidate candidate) => candidate.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).Take(Math.Max(2, options.MaximumCandidates)).ToList(); if (list2.Count > 0) { return new ResolutionResult((list2.Count != 1) ? ResolutionStatus.Ambiguous : ResolutionStatus.Resolved, list2, (list2.Count == 1) ? "Resolved by a unique normalized token match." : "The normalized tokens match multiple items."); } if (!options.FuzzyMatchingEnabled) { return new ResolutionResult(ResolutionStatus.NotFound, null, "No exact match and fuzzy matching is disabled."); } List list3 = (from item in array select new ResolutionCandidate(item, Names(item).Max((string name) => FuzzyMatcher.Similarity(query, name)), "fuzzy name") into candidate where candidate.Score >= options.FuzzyMatchThreshold orderby candidate.Score descending select candidate).ThenBy((ResolutionCandidate candidate) => candidate.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).Take(Math.Max(2, options.MaximumCandidates)).ToList(); if (list3.Count == 0) { return new ResolutionResult(ResolutionStatus.NotFound, null, $"No match met the {options.FuzzyMatchThreshold:0.00} fuzzy threshold."); } bool flag = list3.Count > 1 && list3[0].Score - list3[1].Score <= options.AmbiguityMargin; int status = (flag ? 1 : 0); IEnumerable candidates; if (!flag) { candidates = list3.Take(1); } else { IEnumerable enumerable = list3; candidates = enumerable; } return new ResolutionResult((ResolutionStatus)status, candidates, flag ? "Top fuzzy matches are within the ambiguity margin." : "Resolved by fuzzy matching."); } private static List ExactStage(IEnumerable items, string query, Func selector, string reason) { return (from item in items where string.Equals(selector(item), query, StringComparison.OrdinalIgnoreCase) select new ResolutionCandidate(item, 1.0, reason)).OrderBy((ResolutionCandidate candidate) => candidate.Item.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToList(); } private static ResolutionResult FinishExact(IReadOnlyList candidates, string stage) { return new ResolutionResult((candidates.Count != 1) ? ResolutionStatus.Ambiguous : ResolutionStatus.Resolved, candidates, (candidates.Count == 1) ? ("Resolved by " + stage + ".") : ("Multiple items share the same " + stage + ".")); } private static IEnumerable Names(WikiItem item) { yield return item.DisplayName; yield return item.InternalName; foreach (string alias in item.Aliases) { yield return alias; } } private static bool ContainsEveryToken(string candidate, IReadOnlyList queryTokens) { if (queryTokens.Count == 0) { return false; } HashSet hashSet = new HashSet(TextNormalizer.Tokens(candidate), StringComparer.Ordinal); return queryTokens.All(hashSet.Contains); } } public static class FuzzyMatcher { public static double Similarity(string left, string right) { left = TextNormalizer.Normalize(left); right = TextNormalizer.Normalize(right); if (left == right) { return 1.0; } if (left.Length == 0 || right.Length == 0) { return 0.0; } int num = LevenshteinDistance(left, right); double num2 = 1.0 - (double)num / (double)Math.Max(left.Length, right.Length); HashSet first = new HashSet(TextNormalizer.Tokens(left)); HashSet second = new HashSet(TextNormalizer.Tokens(right)); int num3 = first.Union(second).Count(); double num4 = ((num3 == 0) ? 0.0 : ((double)first.Intersect(second).Count() / (double)num3)); return Math.Max(num2, num2 * 0.65 + num4 * 0.35); } public static int LevenshteinDistance(string left, string right) { if (left.Length == 0) { return right.Length; } if (right.Length == 0) { return left.Length; } int[] array = new int[right.Length + 1]; int[] array2 = new int[right.Length + 1]; for (int i = 0; i <= right.Length; i++) { array[i] = i; } for (int j = 1; j <= left.Length; j++) { array2[0] = j; for (int k = 1; k <= right.Length; k++) { int val = array[k - 1] + ((left[j - 1] != right[k - 1]) ? 1 : 0); array2[k] = Math.Min(Math.Min(array2[k - 1] + 1, array[k] + 1), val); } int[] array3 = array; array = array2; array2 = array3; } return array[right.Length]; } } public static class TextNormalizer { private static readonly Regex RichText = new Regex("<[^>]+>", RegexOptions.Compiled); private static readonly Regex CamelBoundary = new Regex("(?<=[a-z0-9])(?=[A-Z])", RegexOptions.Compiled); private static readonly Regex Whitespace = new Regex("\\s+", RegexOptions.Compiled); public static string Normalize(string? value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } string input = RichText.Replace(value, " "); input = CamelBoundary.Replace(input, " "); input = input.Normalize(NormalizationForm.FormKC).ToLowerInvariant(); StringBuilder stringBuilder = new StringBuilder(input.Length); string text = input; foreach (char c in text) { UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); if (char.IsLetterOrDigit(c) || unicodeCategory == UnicodeCategory.NonSpacingMark) { stringBuilder.Append(c); continue; } if (!char.IsWhiteSpace(c)) { switch (c) { case '-': case '/': case '\\': case '_': break; default: if (char.IsPunctuation(c) || char.IsSymbol(c)) { stringBuilder.Append(' '); } continue; case '\'': case '’': continue; } } stringBuilder.Append(' '); } return Whitespace.Replace(stringBuilder.ToString(), " ").Trim(); } public static string Unquote(string? value, out bool wasQuoted) { string text = (value ?? string.Empty).Trim(); wasQuoted = text.Length >= 2 && ((text[0] == '"' && text[text.Length - 1] == '"') || (text[0] == '\'' && text[text.Length - 1] == '\'')); if (!wasQuoted) { return text; } return text.Substring(1, text.Length - 2).Trim(); } public static IReadOnlyList Tokens(string? value) { return Normalize(value).Split(new char[1] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.Ordinal).ToArray(); } } } namespace MimirsWell.Core.Planning { public sealed class ProductionPlannerLimits { public const int HardMaximumDepth = 12; public const int HardMaximumNodes = 256; public const int HardMaximumAlternativesPerNode = 8; public const long HardMaximumTargetQuantity = 1000000L; public const double HardMaximumComputedQuantity = 1000000000.0; public const int HardMaximumOutputLines = 200; public static readonly TimeSpan HardMaximumExecutionTime = TimeSpan.FromMilliseconds(50.0); public int MaximumDepth { get; } public int MaximumNodes { get; } public int MaximumAlternativesPerNode { get; } public long MaximumTargetQuantity { get; } public double MaximumComputedQuantity { get; } public int MaximumOutputLines { get; } public TimeSpan MaximumExecutionTime { get; } public ProductionPlannerLimits(int maximumDepth = 12, int maximumNodes = 256, int maximumAlternativesPerNode = 8, long maximumTargetQuantity = 1000000L, double maximumComputedQuantity = 1000000000.0, int maximumOutputLines = 200, TimeSpan? maximumExecutionTime = null) { MaximumDepth = Bound(maximumDepth, 1, 12); MaximumNodes = Bound(maximumNodes, 1, 256); MaximumAlternativesPerNode = Bound(maximumAlternativesPerNode, 1, 8); MaximumTargetQuantity = Math.Max(1L, Math.Min(1000000L, maximumTargetQuantity)); MaximumComputedQuantity = (double.IsNaN(maximumComputedQuantity) ? 1.0 : Math.Max(1.0, Math.Min(1000000000.0, maximumComputedQuantity))); MaximumOutputLines = Bound(maximumOutputLines, 3, 200); TimeSpan timeSpan = maximumExecutionTime ?? HardMaximumExecutionTime; MaximumExecutionTime = ((timeSpan < TimeSpan.Zero) ? TimeSpan.Zero : ((timeSpan > HardMaximumExecutionTime) ? HardMaximumExecutionTime : timeSpan)); } private static int Bound(int value, int minimum, int maximum) { return Math.Max(minimum, Math.Min(maximum, value)); } } public sealed class ProductionPlan { public string Title { get; } public IReadOnlyList Lines { get; } public ProductionPlan(string title, IEnumerable lines) { Title = title ?? string.Empty; Lines = Array.AsReadOnly((lines ?? throw new ArgumentNullException("lines")).ToArray()); } } public sealed class ProductionPlanner { private sealed class PlannerContext { private readonly Stopwatch stopwatch = Stopwatch.StartNew(); private readonly List lines = new List(); private int nodeCount; public WikiIndex Index { get; } public ProductionPlannerLimits Limits { get; } public WikiVisibilitySnapshot Visibility { get; } public IReadOnlyList Lines => lines; public bool Stopped { get; private set; } public PlannerContext(WikiIndex index, ProductionPlannerLimits limits, WikiVisibilitySnapshot visibility) { Index = index; Limits = limits; Visibility = visibility; } public string Provenance(string kind, string identifier) { if (!Visibility.IsRestricted) { return kind + " " + identifier; } return "indexed " + kind + " edge (identifier hidden by policy)"; } public bool Add(string line) { if (Stopped || !CheckTime()) { return false; } if (lines.Count >= Limits.MaximumOutputLines - 1) { AddStop($"output bound {Limits.MaximumOutputLines} lines including title reached"); return false; } lines.Add(line); return true; } public void AddStop(string reason) { if (!Stopped) { Stopped = true; string text = "[Planner stopped: " + reason + ".]"; if (lines.Count >= Limits.MaximumOutputLines - 1) { lines[lines.Count - 1] = text; } else { lines.Add(text); } } } public void AddBranchStop(string reason) { Add("[Branch stopped: " + reason + ".]"); } public bool CheckBudget() { if (Stopped) { return false; } return CheckTime(); } public bool TryVisitNode() { if (!CheckBudget()) { return false; } if (nodeCount >= Limits.MaximumNodes) { AddStop($"node bound {Limits.MaximumNodes} reached"); return false; } nodeCount++; return true; } public bool ValidQuantity(double quantity) { if (!double.IsNaN(quantity) && !double.IsInfinity(quantity) && quantity >= 0.0) { return quantity <= Limits.MaximumComputedQuantity; } return false; } public double? Multiply(double left, double right, string itemName) { double num = left * right; if (!ValidQuantity(num)) { AddBranchStop("computed quantity for " + itemName + " exceeded " + Limits.MaximumComputedQuantity.ToString("N0", CultureInfo.InvariantCulture)); return null; } return num; } public double? AddQuantities(double left, double right, string itemName) { double num = left + right; if (!ValidQuantity(num)) { AddBranchStop("computed quantity for " + itemName + " exceeded " + Limits.MaximumComputedQuantity.ToString("N0", CultureInfo.InvariantCulture)); return null; } return num; } private bool CheckTime() { if (stopwatch.Elapsed < Limits.MaximumExecutionTime) { return true; } AddStop($"execution-time bound {Limits.MaximumExecutionTime.TotalMilliseconds:0} ms reached"); return false; } } private sealed class ExpansionResult { private readonly Dictionary leaves = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary stations = new Dictionary(StringComparer.CurrentCultureIgnoreCase); public bool Deterministic { get; private set; } public IReadOnlyDictionary Leaves => new ReadOnlyDictionary(leaves); public IReadOnlyDictionary Stations => new ReadOnlyDictionary(stations); private ExpansionResult(bool deterministic) { Deterministic = deterministic; } public static ExpansionResult EmptyDeterministic() { return new ExpansionResult(deterministic: true); } public static ExpansionResult Unresolved() { return new ExpansionResult(deterministic: false); } public static ExpansionResult Leaf(string itemId, string itemName, double quantity) { ExpansionResult expansionResult = new ExpansionResult(deterministic: true); expansionResult.leaves[itemId] = new LeafTotal(itemName, quantity); return expansionResult; } public void Merge(PlannerContext context, ExpansionResult child) { Deterministic &= child.Deterministic; if (!child.Deterministic) { return; } foreach (KeyValuePair leaf in child.leaves) { if (leaves.TryGetValue(leaf.Key, out LeafTotal value)) { double? num = context.AddQuantities(value.Quantity, leaf.Value.Quantity, value.ItemName); if (!num.HasValue) { Deterministic = false; return; } leaves[leaf.Key] = new LeafTotal(value.ItemName, num.Value); } else { leaves[leaf.Key] = leaf.Value; } } foreach (KeyValuePair station in child.stations) { AddStation(station.Key, station.Value); } } public void AddStation(string? name, int level) { if (!string.IsNullOrWhiteSpace(name)) { if (!stations.TryGetValue(name, out var value) || level > value) { stations[name] = level; } } } } private sealed class LeafTotal { public string ItemName { get; } public double Quantity { get; } public LeafTotal(string itemName, double quantity) { ItemName = itemName; Quantity = quantity; } } private sealed class CalculatedRequirement { public string ItemId { get; } public string ItemName { get; } public double Quantity { get; } public CalculatedRequirement(string itemId, string itemName, double quantity) { ItemId = itemId; ItemName = itemName; Quantity = quantity; } } private sealed class RequirementPhase { public string Name { get; } public IReadOnlyList Requirements { get; } public RequirementPhase(string name, IReadOnlyList requirements) { Name = name; Requirements = requirements; } } private sealed class Candidate { public WikiRecipe? Recipe { get; } public WikiConversion? Conversion { get; } public WikiAcquisitionSource? Source { get; } public Candidate(WikiRecipe recipe) { Recipe = recipe; } public Candidate(WikiConversion conversion) { Conversion = conversion; } public Candidate(WikiAcquisitionSource source) { Source = source; } } private readonly ProductionPlannerLimits limits; public ProductionPlanner(ProductionPlannerLimits? limits = null) { this.limits = limits ?? new ProductionPlannerLimits(12, 256, 8, 1000000L); } public ProductionPlan Plan(WikiIndex index, WikiItem target, long requestedQuantity, int requestedQuality) { return Plan(index, target, requestedQuantity, requestedQuality, WikiVisibilitySnapshot.AllLoaded); } public ProductionPlan Plan(WikiIndex index, WikiItem target, long requestedQuantity, int requestedQuality, WikiVisibilitySnapshot visibility) { if (index == null) { throw new ArgumentNullException("index"); } if (target == null) { throw new ArgumentNullException("target"); } if (visibility == null) { throw new ArgumentNullException("visibility"); } string title = target.DisplayName + " plan"; PlannerContext plannerContext = new PlannerContext(index, limits, visibility); if (requestedQuantity < 1 || requestedQuantity > limits.MaximumTargetQuantity) { plannerContext.AddStop("target quantity must be between 1 and " + limits.MaximumTargetQuantity.ToString("N0", CultureInfo.InvariantCulture)); return new ProductionPlan(title, plannerContext.Lines); } int num = Math.Max(1, Math.Min(target.MaxQuality, requestedQuality)); plannerContext.Add($"Requested: {requestedQuantity.ToString(CultureInfo.InvariantCulture)} {target.DisplayName} at quality {num}."); if (num != requestedQuality) { plannerContext.Add($"Requested quality {requestedQuality} was clamped to the loaded maximum quality {num}."); } plannerContext.Add($"Bounds: depth {limits.MaximumDepth}, nodes {limits.MaximumNodes}, alternatives {limits.MaximumAlternativesPerNode}, answer lines {limits.MaximumOutputLines}, time {limits.MaximumExecutionTime.TotalMilliseconds:0} ms."); ExpansionResult expansionResult = ExpandNode(plannerContext, target.Id, target.DisplayName, requestedQuantity, num, 0, Array.Empty(), string.Empty); if (expansionResult.Deterministic && expansionResult.Leaves.Count > 0) { RenderSummary(plannerContext, expansionResult, "Deterministic raw or acquisition-stop totals"); } else if (!plannerContext.Stopped) { plannerContext.Add("No combined total is claimed because at least one production or ingredient choice remains unresolved."); } return new ProductionPlan(title, plannerContext.Lines); } private static ExpansionResult ExpandNode(PlannerContext context, string itemId, string itemName, double quantity, int quality, int depth, IReadOnlyList path, string indent) { if (!context.CheckBudget()) { return ExpansionResult.Unresolved(); } if (!context.Visibility.IsVisible(itemId)) { context.Add($"{indent}- {Number(quantity)} [undiscovered item] [stopped: hidden by {context.Visibility.Policy}]"); return ExpansionResult.Unresolved(); } if (!context.ValidQuantity(quantity)) { context.AddBranchStop("computed quantity for " + itemName + " exceeded " + context.Limits.MaximumComputedQuantity.ToString("N0", CultureInfo.InvariantCulture)); return ExpansionResult.Unresolved(); } if (path.Any((string value) => string.Equals(value, itemId, StringComparison.OrdinalIgnoreCase))) { context.Add(indent + "- " + Number(quantity) + " " + itemName + " [stopped: cycle detected at " + itemId + "]"); return ExpansionResult.Unresolved(); } if (!context.TryVisitNode()) { return ExpansionResult.Unresolved(); } context.Add(indent + "- " + Number(quantity) + " " + itemName + ((quality > 1) ? $" (quality {quality})" : string.Empty)); if (depth >= context.Limits.MaximumDepth) { context.Add($"{indent} [stopped: depth bound {context.Limits.MaximumDepth} reached]"); return ExpansionResult.Unresolved(); } WikiRecipe[] array = context.Index.RecipesForOutput(itemId).OrderBy((WikiRecipe value) => value.Id, StringComparer.OrdinalIgnoreCase).ToArray(); WikiConversion[] array2 = context.Index.ConversionsForOutput(itemId).OrderBy((WikiConversion value) => value.Id, StringComparer.OrdinalIgnoreCase).ToArray(); WikiAcquisitionSource[] array3 = context.Index.SourcesForOutput(itemId).OrderBy((WikiAcquisitionSource value) => value.Id, StringComparer.OrdinalIgnoreCase).ToArray(); int num = array.Length + array2.Length; string[] path2 = path.Concat(new string[1] { itemId }).ToArray(); if (num == 0) { if (array3.Length == 0) { context.Add(indent + " Acquisition stop: no indexed production or direct source."); } else { RenderSources(context, array3, indent + " "); } return ExpansionResult.Leaf(itemId, itemName, quantity); } int num2 = num + array3.Length; if (num2 > 1) { context.Add($"{indent} {num2} loaded production/source choices; none selected."); } if (num2 > context.Limits.MaximumAlternativesPerNode) { context.Add($"{indent} Showing {context.Limits.MaximumAlternativesPerNode} of {num2}; alternative bound reached."); } Candidate[] array4 = array.Select((WikiRecipe recipe) => new Candidate(recipe)).Concat(array2.Select((WikiConversion conversion) => new Candidate(conversion))).Concat(array3.Select((WikiAcquisitionSource source) => new Candidate(source))) .Take(context.Limits.MaximumAlternativesPerNode) .ToArray(); List list = new List(); for (int num3 = 0; num3 < array4.Length; num3++) { if (context.Stopped) { break; } Candidate candidate = array4[num3]; string text = indent + " "; if (candidate.Recipe != null) { ExpansionResult expansionResult = RenderRecipe(context, candidate.Recipe, quantity, quality, depth, path2, text, num3 + 1, num2, num2 == 1); list.Add(expansionResult); if (depth == 0 && num2 > 1 && expansionResult.Deterministic) { RenderSummary(context, expansionResult, $"Choice {num3 + 1} deterministic raw or acquisition-stop totals"); } } else if (candidate.Conversion != null) { ExpansionResult expansionResult2 = RenderConversion(context, candidate.Conversion, quantity, depth, path2, text, num3 + 1, num2, num2 == 1); list.Add(expansionResult2); if (depth == 0 && num2 > 1 && expansionResult2.Deterministic) { RenderSummary(context, expansionResult2, $"Choice {num3 + 1} deterministic raw or acquisition-stop totals"); } } else if (candidate.Source != null) { context.Add($"{text}Choice {num3 + 1}/{num2}: direct acquisition"); RenderSource(context, candidate.Source, text + " "); list.Add(ExpansionResult.Leaf(itemId, itemName, quantity)); } } if (num2 != 1 || array4.Length != num2) { return ExpansionResult.Unresolved(); } if (list.Count != 1) { return ExpansionResult.Unresolved(); } return list[0]; } private static ExpansionResult RenderRecipe(PlannerContext context, WikiRecipe recipe, double quantity, int quality, int depth, IReadOnlyList path, string indent, int choiceNumber, int choiceCount, bool expandDependencies) { long? num = CeilingBatches(context, quantity, recipe.OutputAmount, recipe.OutputName); if (!num.HasValue) { return ExpansionResult.Unresolved(); } double? num2 = context.Multiply(num.Value, recipe.OutputAmount, recipe.OutputName); if (!num2.HasValue) { return ExpansionResult.Unresolved(); } double value = num2.Value; string text = context.Provenance("recipe", recipe.Id); context.Add(string.Format("{0}Choice {1}/{2}: craft [provenance: {3}; certainty: {4}]", indent, choiceNumber, choiceCount, text, recipe.RequireOnlyOneIngredient ? "choice required" : "deterministic")); context.Add(string.Format("{0} Batches: {1} x output {2} = {3}{4}.", indent, num.Value, recipe.OutputAmount, Number(value), (value > quantity) ? ("; surplus " + Number(value - quantity)) : string.Empty)); RenderRecipeStation(context, recipe, quality, indent + " "); IReadOnlyList readOnlyList = BuildRecipePhases(context, recipe, num.Value, quantity, quality); if (readOnlyList == null) { return ExpansionResult.Unresolved(); } foreach (RequirementPhase item in readOnlyList) { RenderPhase(context, item, recipe.RequireOnlyOneIngredient, text, indent + " "); } if (recipe.RequireOnlyOneIngredient) { context.Add(indent + " OR ingredients are alternatives; no ingredient or mixture was selected and no total is claimed."); return ExpansionResult.Unresolved(); } if (!expandDependencies) { context.Add(indent + " Choice frontier: inputs are shown but not recursively expanded until a production/source choice is selected."); return ExpansionResult.Unresolved(); } IReadOnlyList readOnlyList2 = CombineRequirements(context, readOnlyList.SelectMany((RequirementPhase requirementPhase) => requirementPhase.Requirements)); if (readOnlyList2 == null) { return ExpansionResult.Unresolved(); } ExpansionResult expansionResult = ExpansionResult.EmptyDeterministic(); foreach (CalculatedRequirement item2 in readOnlyList2) { context.Add(indent + " Requires: " + Number(item2.Quantity) + " " + item2.ItemName + " [provenance: " + text + "; certainty: deterministic]"); ExpansionResult child = ExpandNode(context, item2.ItemId, item2.ItemName, item2.Quantity, 1, depth + 1, path, indent + " "); expansionResult.Merge(context, child); } expansionResult.AddStation(recipe.StationName, recipe.StationLevelForTargetQuality(quality)); return expansionResult; } private static ExpansionResult RenderConversion(PlannerContext context, WikiConversion conversion, double quantity, int depth, IReadOnlyList path, string indent, int choiceNumber, int choiceCount, bool expandDependencies) { long? num = CeilingBatches(context, quantity, conversion.OutputAmount, conversion.OutputName); if (!num.HasValue) { return ExpansionResult.Unresolved(); } double? num2 = context.Multiply(num.Value, conversion.OutputAmount, conversion.OutputName); if (!num2.HasValue) { return ExpansionResult.Unresolved(); } double value = num2.Value; string text = context.Provenance("conversion", conversion.Id); context.Add(string.Format("{0}Choice {1}/{2}: {3} at {4} [provenance: {5}; certainty: {6}]", indent, choiceNumber, choiceCount, ProcessLabel(conversion.Kind), conversion.StationName, text, conversion.RequireOnlyOneIngredient ? "choice required" : "deterministic")); context.Add(string.Format("{0} Batches: {1} x output {2} = {3}{4}.", indent, num.Value, conversion.OutputAmount, Number(value), (value > quantity) ? ("; surplus " + Number(value - quantity)) : string.Empty)); List list = new List(); foreach (WikiConversionRequirement requirement in conversion.Requirements) { if (!context.CheckBudget()) { return ExpansionResult.Unresolved(); } if (list.Count >= context.Limits.MaximumNodes) { context.AddBranchStop($"{text} exceeds the {context.Limits.MaximumNodes} requirement/node bound"); return ExpansionResult.Unresolved(); } if (!requirement.Amount.HasValue || requirement.Amount.Value <= 0.0) { context.Add(indent + " " + requirement.Role + ": " + context.Visibility.ItemName(requirement.ItemId, requirement.ItemName) + " [quantity unavailable; edge isolated; provenance: " + text + "]"); return ExpansionResult.Unresolved(); } double? num3 = context.Multiply(requirement.Amount.Value, num.Value, context.Visibility.ItemName(requirement.ItemId, requirement.ItemName)); if (!num3.HasValue) { return ExpansionResult.Unresolved(); } list.Add(new CalculatedRequirement(requirement.ItemId, context.Visibility.ItemName(requirement.ItemId, requirement.ItemName), num3.Value)); } RequirementPhase phase = new RequirementPhase("Processing inputs", list); RenderPhase(context, phase, conversion.RequireOnlyOneIngredient, text, indent + " "); if (conversion.RequireOnlyOneIngredient) { context.Add(indent + " OR inputs are alternatives; none was selected and no total is claimed."); return ExpansionResult.Unresolved(); } if (!expandDependencies) { context.Add(indent + " Choice frontier: inputs are shown but not recursively expanded until a production/source choice is selected."); return ExpansionResult.Unresolved(); } ExpansionResult expansionResult = ExpansionResult.EmptyDeterministic(); foreach (CalculatedRequirement item in list) { context.Add(indent + " Requires: " + Number(item.Quantity) + " " + item.ItemName + " [provenance: " + text + "; certainty: deterministic]"); ExpansionResult child = ExpandNode(context, item.ItemId, item.ItemName, item.Quantity, 1, depth + 1, path, indent + " "); expansionResult.Merge(context, child); } expansionResult.AddStation(conversion.StationName, 0); return expansionResult; } private static IReadOnlyList? BuildRecipePhases(PlannerContext context, WikiRecipe recipe, long batches, double targetQuantity, int quality) { List list = new List(); IReadOnlyList readOnlyList = CalculateRecipeRequirements(context, recipe.Requirements, 1, batches); if (readOnlyList == null) { return null; } list.Add(new RequirementPhase("Initial craft", readOnlyList)); for (int i = 2; i <= quality; i++) { if (!context.CheckBudget()) { return null; } IReadOnlyList readOnlyList2 = CalculateRecipeRequirements(context, recipe.Requirements, i, targetQuantity); if (readOnlyList2 == null) { return null; } list.Add(new RequirementPhase($"Upgrade to quality {i}", readOnlyList2)); } return list; } private static IReadOnlyList? CalculateRecipeRequirements(PlannerContext context, IEnumerable requirements, int targetQuality, double multiplier) { List list = new List(); foreach (WikiRequirement requirement in requirements) { if (!context.CheckBudget()) { return null; } if (list.Count >= context.Limits.MaximumNodes) { context.AddBranchStop($"recipe requirements exceed the {context.Limits.MaximumNodes} requirement/node bound"); return null; } int num = requirement.AmountForTargetQuality(targetQuality); if (num > 0) { double? num2 = context.Multiply(num, multiplier, context.Visibility.ItemName(requirement.ItemId, requirement.ItemName)); if (!num2.HasValue) { return null; } list.Add(new CalculatedRequirement(requirement.ItemId, context.Visibility.ItemName(requirement.ItemId, requirement.ItemName), num2.Value)); } } return list; } private static IReadOnlyList? CombineRequirements(PlannerContext context, IEnumerable requirements) { Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (CalculatedRequirement requirement in requirements) { if (!context.CheckBudget()) { return null; } if (!dictionary.TryGetValue(requirement.ItemId, out var value)) { dictionary[requirement.ItemId] = requirement; continue; } double? num = context.AddQuantities(value.Quantity, requirement.Quantity, value.ItemName); if (!num.HasValue) { return null; } dictionary[requirement.ItemId] = new CalculatedRequirement(value.ItemId, value.ItemName, num.Value); } return dictionary.Values.OrderBy((CalculatedRequirement calculatedRequirement) => calculatedRequirement.ItemName, StringComparer.CurrentCultureIgnoreCase).ToArray(); } private static void RenderPhase(PlannerContext context, RequirementPhase phase, bool isOr, string provenance, string indent) { string separator = (isOr ? " OR " : ", "); string[] array = (from value in phase.Requirements.Take(context.Limits.MaximumAlternativesPerNode) select Number(value.Quantity) + " " + value.ItemName).ToArray(); int num = phase.Requirements.Count - array.Length; string text = ((array.Length == 0) ? "no indexed ingredients" : string.Join(separator, array)); if (num > 0) { text += (isOr ? string.Format("; {0} more OR choice{1} not shown because the alternative bound is {2}", num, (num == 1) ? string.Empty : "s", context.Limits.MaximumAlternativesPerNode) : string.Format("; plus {0} more loaded requirement{1} expanded below", num, (num == 1) ? string.Empty : "s")); } context.Add(indent + phase.Name + ": " + text + " [provenance: " + provenance + "; certainty: " + (isOr ? "choice required" : "deterministic") + "]"); } private static void RenderRecipeStation(PlannerContext context, WikiRecipe recipe, int quality, string indent) { if (string.IsNullOrWhiteSpace(recipe.StationName)) { context.Add(indent + "Station: none (hand craft)."); return; } int num = recipe.StationLevelForTargetQuality(1); int num2 = recipe.StationLevelForTargetQuality(quality); context.Add((quality > 1) ? $"{indent}Station: {recipe.StationName} level {num} to craft; level {num2} for quality {quality}." : $"{indent}Station: {recipe.StationName} level {num}."); } private static void RenderSources(PlannerContext context, IReadOnlyList sources, string indent) { int num = Math.Min(sources.Count, context.Limits.MaximumAlternativesPerNode); if (sources.Count > 1) { context.Add($"{indent}{sources.Count} direct acquisition sources; none selected."); } if (sources.Count > num) { context.Add($"{indent}Showing {num} of {sources.Count}; alternative bound reached."); } for (int i = 0; i < num; i++) { RenderSource(context, sources[i], indent); } } private static void RenderSource(PlannerContext context, WikiAcquisitionSource source, string indent) { string text = ((source.MinimumAmount == source.MaximumAmount) ? source.MinimumAmount.ToString(CultureInfo.InvariantCulture) : $"{source.MinimumAmount}-{source.MaximumAmount}"); string text2 = (string.IsNullOrWhiteSpace(source.SourceMod) ? string.Empty : ("; mod " + source.SourceMod)); context.Add(indent + "Source: " + SourceKindLabel(source.Kind) + " via " + source.SourceName + "; loaded yield " + text + " [provenance: adapter " + source.Adapter + ", prefab " + source.SourceInternalName + text2 + "; certainty: " + SourceCertaintyLabel(source.Certainty) + "]"); } private static void RenderSummary(PlannerContext context, ExpansionResult result, string heading) { context.Add(heading + ":"); foreach (LeafTotal item in result.Leaves.Values.OrderBy((LeafTotal value) => value.ItemName, StringComparer.CurrentCultureIgnoreCase)) { if (context.Stopped) { return; } context.Add("- " + Number(item.Quantity) + " " + item.ItemName); } if (result.Stations.Count <= 0) { return; } context.Add("Stations:"); foreach (KeyValuePair item2 in result.Stations.OrderBy, string>((KeyValuePair value) => value.Key, StringComparer.CurrentCultureIgnoreCase)) { if (context.Stopped) { break; } context.Add((item2.Value > 0) ? $"- {item2.Key} level {item2.Value}" : ("- " + item2.Key)); } } private static long? CeilingBatches(PlannerContext context, double quantity, int outputAmount, string itemName) { double num = Math.Ceiling(quantity / (double)Math.Max(1, outputAmount)); if (double.IsNaN(num) || double.IsInfinity(num) || num > 9.223372036854776E+18 || num > context.Limits.MaximumComputedQuantity) { context.AddBranchStop("batch count for " + itemName + " exceeded the computed-quantity bound"); return null; } return (long)num; } private static string Number(double value) { if (!(Math.Abs(value - Math.Round(value)) < 1E-07)) { return value.ToString("0.###", CultureInfo.InvariantCulture); } return Math.Round(value).ToString("0", CultureInfo.InvariantCulture); } private static string ProcessLabel(WikiProcessKind kind) { return kind switch { WikiProcessKind.Cooking => "cook", WikiProcessKind.Fermenting => "ferment", WikiProcessKind.Incinerating => "incinerate", _ => "process", }; } private static string SourceKindLabel(WikiSourceKind kind) { return kind switch { WikiSourceKind.CreatureDrop => "creature drop", WikiSourceKind.Purchased => "purchase", WikiSourceKind.PassivelyProduced => "passive production", WikiSourceKind.Harvested => "harvest", WikiSourceKind.MinedOrDestroyed => "mined or destroyed", _ => "fishing", }; } private static string SourceCertaintyLabel(WikiSourceCertainty certainty) { return certainty switch { WikiSourceCertainty.Chance => "chance", WikiSourceCertainty.Conditional => "conditional", _ => "guaranteed", }; } } } namespace MimirsWell.Core.Interpretation { public sealed class InterpretationContext { public string Language { get; } public bool NaturalLanguageEnabled { get; } public InterpretationContext(string language, bool naturalLanguageEnabled) { Language = language ?? string.Empty; NaturalLanguageEnabled = naturalLanguageEnabled; } } public interface IQuestionInterpreter { WikiQuery Interpret(string input, InterpretationContext context); } public sealed class RuleBasedQuestionInterpreter : IQuestionInterpreter { private const int MaximumInputLength = 512; private static readonly Regex Prefix = new Regex("^\\s*/?wiki(?:\\s+|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex Quality = new Regex("(?:\\s+(?:at|quality|q)\\s*(?:quality\\s*)?(?[1-9][0-9]*))\\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex Compare = new Regex("^\\s*(?.+?)\\s+(?:and|vs\\.?|versus)\\s+(?.+?)\\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhyComparison = new Regex("^why\\s+(?.+?)\\s+is\\s+(?:stronger|better)\\s+than\\s+(?.+?)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex MetricSuffix = new Regex("^(?.+?)\\s+by\\s+(?[a-z][a-z0-9 -]*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhatDrops = new Regex("^what\\s+drops\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhatProduces = new Regex("^what\\s+produces\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhatSells = new Regex("^what\\s+does\\s+(?:the\\s+)?(?.+?)\\s+sell$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhereMine = new Regex("^where\\s+(?:do|can)\\s+i\\s+mine\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WhereCatch = new Regex("^where\\s+(?:do|can)\\s+i\\s+catch\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex LeadingQuantity = new Regex("^(?-?[0-9]+)\\s+(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex TrailingQuantity = new Regex("^(?.+?)\\s+(?:quantity|qty)\\s+(?-?[0-9]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly (Regex Pattern, WikiIntent Intent, string Diagnostic)[] NaturalPatterns = new(Regex, WikiIntent, string)[9] { (new Regex("^(?:how\\s+do\\s+i|how\\s+can\\s+i)\\s+(?:make|craft)\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.RecipeLookup, "natural recipe form"), (new Regex("^(?:what\\s+does\\s+it\\s+cost\\s+to|how\\s+do\\s+i)\\s+upgrade\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.UpgradeLookup, "natural upgrade form"), (new Regex("^what\\s+(?:can|could)\\s+i\\s+(?:make|craft|build)\\s+with\\s+(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.MaterialUsage, "natural material-usage form"), (new Regex("^where\\s+(?:do|can)\\s+i\\s+(?:get|find|obtain)\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.AcquisitionSource, "natural acquisition-source form"), (new Regex("^where\\s+to\\s+(?:get|find|obtain)\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.AcquisitionSource, "natural acquisition shorthand"), (new Regex("^where\\s+(?:does|do)\\s+(?:an?\\s+|the\\s+)?(?.+?)\\s+come\\s+from$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.AcquisitionSource, "natural acquisition-origin form"), (new Regex("^where\\s+do\\s+i\\s+(?:make|craft)\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.CraftingStation, "natural station form"), (new Regex("^what\\s+(?:can|could)\\s+i\\s+(?:make|craft)\\s+at\\s+(?:the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.CraftingStation, "natural station-contents form"), (new Regex("^what\\s+is\\s+stronger\\s+than\\s+(?:an?\\s+|the\\s+)?(?.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled), WikiIntent.StrongerAlternatives, "natural stronger-than form") }; public WikiQuery Interpret(string input, InterpretationContext context) { if (context == null) { throw new ArgumentNullException("context"); } string text = Prefix.Replace(input ?? string.Empty, string.Empty, 1).Trim(); if (text.Length == 0) { return Query(WikiIntent.Help, null, null, null, 1.0, "empty input maps to help"); } if (text.Length > 512) { string diagnostic = $"input exceeds {512} characters"; return Query(WikiIntent.Unsupported, null, null, null, 1.0, diagnostic); } int? quality = null; Match match = Quality.Match(text); if (match.Success && int.TryParse(match.Groups["quality"].Value, out var result)) { quality = result; text = text.Substring(0, match.Index).Trim(); } int num = text.IndexOf(' '); string text2 = ((num < 0) ? text : text.Substring(0, num)).Trim().ToLowerInvariant(); string text3 = ((num < 0) ? string.Empty : text.Substring(num + 1).Trim()); switch (text2) { case "help": return Query(WikiIntent.Help, null, null, quality, 1.0, "explicit help command"); case "status": return Query(WikiIntent.Status, null, null, quality, 1.0, "explicit status command"); case "more": return Query(WikiIntent.More, null, null, quality, 1.0, "explicit pagination command"); case "reindex": return Query(WikiIntent.Reindex, null, null, quality, 1.0, "explicit local reindex command"); case "reload": return Query(WikiIntent.Reload, null, null, quality, 1.0, "explicit configuration reload command"); case "held": return EmptyContextQuery(WikiIntent.ContextHeld, text3, quality, "held-item context shortcut"); case "equipped": return EmptyContextQuery(WikiIntent.ContextEquipped, text3, quality, "equipped-items context shortcut"); case "food": return EmptyContextQuery(WikiIntent.ContextFood, text3, quality, "active-food context shortcut"); case "diag": case "diagnostics": case "diagnostic": return EmptyContextQuery(WikiIntent.Diagnostics, text3, quality, "explicit diagnostics report"); case "mods": return EmptyContextQuery(WikiIntent.ModReport, text3, quality, "explicit mod-attribution report"); case "unsupported": return EmptyContextQuery(WikiIntent.UnsupportedReport, text3, quality, "explicit unsupported-content report"); case "info": return EntityQuery(WikiIntent.ItemInfo, text3, quality, "explicit info command"); case "recipe": return EntityQuery(WikiIntent.RecipeLookup, text3, quality, "explicit recipe command"); case "plan": return PlanQuery(text3, quality, "explicit plan command"); case "chain": return PlanQuery(text3, quality, "explicit chain command"); case "ingredients": return PlanQuery(text3, quality, "explicit ingredients command"); case "upgrade": case "upgrades": return EntityQuery(WikiIntent.UpgradeLookup, text3, quality, "explicit upgrade command"); case "uses": case "use": return EntityQuery(WikiIntent.MaterialUsage, text3, quality, "explicit uses command"); case "obtain": case "source": case "sources": return EntityQuery(WikiIntent.AcquisitionSource, text3, quality, "explicit source command"); case "drops": return FilteredEntityQuery(WikiIntent.SourceInventory, text3, quality, "sourceKind", WikiSourceKind.CreatureDrop.ToString(), "explicit creature-drops command"); case "sell": case "sells": return FilteredEntityQuery(WikiIntent.SourceInventory, text3, quality, "sourceKind", WikiSourceKind.Purchased.ToString(), "explicit trader-inventory command"); case "produces": return FilteredEntityQuery(WikiIntent.AcquisitionSource, text3, quality, "sourceKind", WikiSourceKind.PassivelyProduced.ToString(), "explicit passive-source command"); case "harvest": return FilteredEntityQuery(WikiIntent.AcquisitionSource, text3, quality, "sourceKind", WikiSourceKind.Harvested.ToString(), "explicit harvest-source command"); case "mine": case "destroy": case "mined": return FilteredEntityQuery(WikiIntent.AcquisitionSource, text3, quality, "sourceKind", WikiSourceKind.MinedOrDestroyed.ToString(), "explicit environmental-source command"); case "fish": case "fishing": case "catch": return FilteredEntityQuery(WikiIntent.AcquisitionSource, text3, quality, "sourceKind", WikiSourceKind.Fishing.ToString(), "explicit fishing-source command"); case "station": if (!IsCurrentStationTerm(text3)) { return EntityQuery(WikiIntent.CraftingStation, text3, quality, "explicit station command"); } return Query(WikiIntent.ContextStation, null, null, quality, 1.0, "current-station context shortcut"); case "stronger": return MetricEntityQuery(WikiIntent.StrongerAlternatives, StripLeadingThan(text3), quality, "explicit stronger command"); case "compare": return CompareQuery(text3, quality, "explicit compare command"); case "why": return WhyQuery(text, quality, "explicit deterministic explanation"); case "find": return FindQuery(text3, quality, "explicit find command"); default: if (context.NaturalLanguageEnabled) { string metric; Match match2 = WhyComparison.Match(StripMetric(text, out metric)); if (match2.Success) { return Query(WikiIntent.ComparisonExplanation, new string[2] { match2.Groups["left"].Value.Trim(), match2.Groups["right"].Value.Trim() }, MetricFilter(metric), quality, 0.98, "natural deterministic comparison explanation"); } Match match3 = WhatDrops.Match(text); if (match3.Success) { return FilteredEntityQuery(WikiIntent.AcquisitionSource, match3.Groups["entity"].Value, quality, "sourceKind", WikiSourceKind.CreatureDrop.ToString(), "natural creature-drop source form", 0.98); } Match match4 = WhatProduces.Match(text); if (match4.Success) { return FilteredEntityQuery(WikiIntent.AcquisitionSource, match4.Groups["entity"].Value, quality, "sourceKind", WikiSourceKind.PassivelyProduced.ToString(), "natural passive-source form", 0.98); } Match match5 = WhatSells.Match(text); if (match5.Success) { return FilteredEntityQuery(WikiIntent.SourceInventory, match5.Groups["entity"].Value, quality, "sourceKind", WikiSourceKind.Purchased.ToString(), "natural trader-inventory form", 0.98); } Match match6 = WhereMine.Match(text); if (match6.Success) { return FilteredEntityQuery(WikiIntent.AcquisitionSource, match6.Groups["entity"].Value, quality, "sourceKind", WikiSourceKind.MinedOrDestroyed.ToString(), "natural environmental-source form", 0.98); } Match match7 = WhereCatch.Match(text); if (match7.Success) { return FilteredEntityQuery(WikiIntent.AcquisitionSource, match7.Groups["entity"].Value, quality, "sourceKind", WikiSourceKind.Fishing.ToString(), "natural fishing-source form", 0.98); } (Regex, WikiIntent, string)[] naturalPatterns = NaturalPatterns; for (int i = 0; i < naturalPatterns.Length; i++) { (Regex, WikiIntent, string) tuple = naturalPatterns[i]; Match match8 = tuple.Item1.Match(text); if (match8.Success) { if (tuple.Item2 != WikiIntent.StrongerAlternatives) { return EntityQuery(tuple.Item2, match8.Groups["entity"].Value, quality, tuple.Item3, 0.96); } return MetricEntityQuery(tuple.Item2, match8.Groups["entity"].Value, quality, tuple.Item3, 0.96); } } if (text.StartsWith("compare ", StringComparison.OrdinalIgnoreCase)) { return CompareQuery(text.Substring("compare ".Length), quality, "natural compare form"); } if (LooksLikeFilter(text)) { return FindQuery(text, quality, "natural filter form", 0.9); } } return EntityQuery(WikiIntent.ItemInfo, text, quality, "bare entity defaults to item info", 0.84); } } private static WikiQuery CompareQuery(string value, int? quality, string diagnostic) { string metric; string text = StripMetric(value, out metric); Match match = Compare.Match(text); if (!match.Success) { if (!string.IsNullOrWhiteSpace(metric) && !string.IsNullOrWhiteSpace(text)) { return Query(WikiIntent.CategoryRanking, null, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["category"] = text, ["metric"] = metric }, quality, 1.0, "explicit category ranking command"); } return Query(WikiIntent.Unsupported, null, null, null, 1.0, "comparison requires two names separated by 'and', 'vs', or 'versus'"); } return Query(WikiIntent.ItemComparison, new string[2] { match.Groups["left"].Value.Trim(), match.Groups["right"].Value.Trim() }, MetricFilter(metric), quality, 1.0, diagnostic); } private static WikiQuery WhyQuery(string value, int? quality, string diagnostic) { string metric; string input = StripMetric(value, out metric); Match match = WhyComparison.Match(input); if (!match.Success) { return Query(WikiIntent.Unsupported, null, null, null, 1.0, "why requires ' is stronger than '"); } return Query(WikiIntent.ComparisonExplanation, new string[2] { match.Groups["left"].Value.Trim(), match.Groups["right"].Value.Trim() }, MetricFilter(metric), quality, 1.0, diagnostic); } private static WikiQuery MetricEntityQuery(WikiIntent intent, string entity, int? quality, string diagnostic, double confidence = 1.0) { string metric; string text = StripMetric(entity, out metric); if (string.IsNullOrWhiteSpace(text)) { string diagnostic2 = $"{intent} requires an entity name"; return Query(WikiIntent.Unsupported, null, null, null, 1.0, diagnostic2); } return Query(intent, new string[1] { text }, MetricFilter(metric), quality, confidence, diagnostic); } private static string StripMetric(string value, out string? metric) { string text = (value ?? string.Empty).Trim(); Match match = MetricSuffix.Match(text); if (!match.Success) { metric = null; return text; } metric = match.Groups["metric"].Value.Trim(); return match.Groups["value"].Value.Trim(); } private static IDictionary? MetricFilter(string? metric) { if (!string.IsNullOrWhiteSpace(metric)) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["metric"] = metric }; } return null; } private static WikiQuery PlanQuery(string value, int? quality, string diagnostic) { if (string.IsNullOrWhiteSpace(value)) { return Query(WikiIntent.Unsupported, null, null, null, 1.0, "CraftingPlan requires an item name"); } string text = value.Trim(); Match match = TrailingQuantity.Match(text); if (!match.Success) { match = LeadingQuantity.Match(text); } if (!match.Success) { return Query(WikiIntent.CraftingPlan, new string[1] { text }, null, quality, 1.0, diagnostic, 1L); } if (!long.TryParse(match.Groups["quantity"].Value, out var result)) { return Query(WikiIntent.Unsupported, null, null, null, 1.0, "planner quantity is outside the supported integer range"); } return Query(WikiIntent.CraftingPlan, new string[1] { match.Groups["entity"].Value.Trim() }, null, quality, 1.0, diagnostic, result); } private static WikiQuery FindQuery(string expression, int? quality, string diagnostic, double confidence = 1.0) { Dictionary filters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["expression"] = expression.Trim() }; return Query(WikiIntent.Find, null, filters, quality, confidence, diagnostic); } private static WikiQuery EntityQuery(WikiIntent intent, string entity, int? quality, string diagnostic, double confidence = 1.0) { if (string.IsNullOrWhiteSpace(entity)) { string diagnostic2 = $"{intent} requires an entity name"; return Query(WikiIntent.Unsupported, null, null, null, 1.0, diagnostic2); } return Query(intent, new string[1] { entity.Trim() }, null, quality, confidence, diagnostic); } private static WikiQuery EmptyContextQuery(WikiIntent intent, string remainder, int? quality, string diagnostic) { if (!string.IsNullOrWhiteSpace(remainder)) { string diagnostic2 = $"{intent} does not accept an item name; use /wiki info for explicit lookup"; return Query(WikiIntent.Unsupported, null, null, null, 1.0, diagnostic2); } return Query(intent, null, null, quality, 1.0, diagnostic); } private static bool IsCurrentStationTerm(string value) { string text = (value ?? string.Empty).Trim(); if (!text.Equals("nearby", StringComparison.OrdinalIgnoreCase) && !text.Equals("current", StringComparison.OrdinalIgnoreCase)) { return text.Equals("here", StringComparison.OrdinalIgnoreCase); } return true; } private static WikiQuery FilteredEntityQuery(WikiIntent intent, string entity, int? quality, string filterName, string filterValue, string diagnostic, double confidence = 1.0) { if (string.IsNullOrWhiteSpace(entity)) { string diagnostic2 = $"{intent} requires an entity name"; return Query(WikiIntent.Unsupported, null, null, null, 1.0, diagnostic2); } return Query(intent, new string[1] { entity.Trim() }, new Dictionary(StringComparer.OrdinalIgnoreCase) { [filterName] = filterValue }, quality, confidence, diagnostic); } private static WikiQuery Query(WikiIntent intent, IEnumerable? entities = null, IDictionary? filters = null, int? quality = null, double confidence = 1.0, string diagnostic = "", long? quantity = null) { return new WikiQuery(intent, entities, filters, quality, confidence, "RuleBased", diagnostic, quantity); } private static string StripLeadingThan(string value) { string text = value.Trim(); if (!text.StartsWith("than ", StringComparison.OrdinalIgnoreCase)) { return text; } return text.Substring(5).Trim(); } private static bool LooksLikeFilter(string text) { if (Regex.IsMatch(text, "^(?:find\\s+)?(?:foods?|swords?|axes?|bows?|spears?|knives?|polearms?|shields?|armor|tools?|materials?)\\b", RegexOptions.IgnoreCase)) { return Regex.IsMatch(text, "\\b(?:above|below|at\\s+least|at\\s+most|with|dealing)\\b", RegexOptions.IgnoreCase); } return false; } } } namespace MimirsWell.Core.Indexing { public sealed class EnvironmentalQuantityBounds { public int Minimum { get; } public int Maximum { get; } public bool IsVariable => Minimum != Maximum; internal EnvironmentalQuantityBounds(int minimum, int maximum) { Minimum = minimum; Maximum = maximum; } } public static class EnvironmentalQuantityCalculator { public const int MaximumConfiguredRolls = 1024; public const int MaximumResourcePasses = 1024; public const int MaximumRollOperations = 1024; public const int MaximumCalculationStates = 100000; public static EnvironmentalQuantityBounds CalculateGuaranteedSingleOutput(int dropMinimum, int dropMaximum, int stackMinimum, int stackMaximum, bool oneOfEach, bool dontScale, double resourceRate) { if (dropMinimum < 1) { throw new ArgumentOutOfRangeException("dropMinimum", "A guaranteed output requires a positive minimum roll count."); } if (dropMaximum < dropMinimum || dropMaximum > 1024) { throw new ArgumentOutOfRangeException("dropMaximum"); } if (stackMinimum < 1) { throw new ArgumentOutOfRangeException("stackMinimum"); } if (stackMaximum < stackMinimum) { throw new ArgumentOutOfRangeException("stackMaximum"); } if (double.IsNaN(resourceRate) || double.IsInfinity(resourceRate) || resourceRate <= 0.0) { throw new ArgumentOutOfRangeException("resourceRate"); } int num = checked((int)Math.Ceiling(resourceRate)); if (num > 1024) { throw new InvalidOperationException($"Resource rate requires {num} passes; the limit is {1024}."); } int num2 = (oneOfEach ? 1 : dropMaximum); if ((long)num * (long)num2 > 1024) { throw new InvalidOperationException($"Quantity calculation requires more than {1024} bounded roll operations."); } if ((long)(dropMaximum - dropMinimum + 1) * (long)num > 100000) { throw new InvalidOperationException($"Quantity calculation requires more than {100000} bounded states."); } int num3 = int.MaxValue; int num4 = int.MinValue; for (int i = dropMinimum; i <= dropMaximum; i++) { int num5 = i; long num6 = 0L; long num7 = 0L; for (int j = 0; j < num; j++) { bool num8 = j + 1 == num; double num9 = resourceRate % 1.0; if (num9 == 0.0) { num9 = 1.0; } double num10 = (num8 ? num9 : 1.0); if (num10 < 1.0 && num5 > 1) { num5 = Math.Max(1, UnityRoundToInt((double)num5 * num10)); } int num11 = (oneOfEach ? Math.Min(1, num5) : num5); EnvironmentalQuantityBounds environmentalQuantityBounds = StackBounds(stackMinimum, stackMaximum, dontScale, j, num10); checked { num6 += unchecked((long)num11) * unchecked((long)environmentalQuantityBounds.Minimum); num7 += unchecked((long)num11) * unchecked((long)environmentalQuantityBounds.Maximum); } } if (num6 < num3) { num3 = CheckedInt(num6); } if (num7 > num4) { num4 = CheckedInt(num7); } } if (num3 < 1 || num4 < num3) { throw new InvalidOperationException("The loaded environmental path does not guarantee a positive output."); } return new EnvironmentalQuantityBounds(num3, num4); } private static EnvironmentalQuantityBounds StackBounds(int minimum, int maximum, bool dontScale, int pass, double scale) { if (dontScale) { if (pass > 0) { return new EnvironmentalQuantityBounds(0, 0); } int maximum2 = ((maximum > minimum) ? (maximum - 1) : minimum); return new EnvironmentalQuantityBounds(minimum, maximum2); } int val = UnityRoundToInt((double)minimum * scale); int val2 = UnityRoundToInt((double)maximum * scale); return new EnvironmentalQuantityBounds(Math.Max(1, val), Math.Max(1, val2)); } private static int UnityRoundToInt(double value) { return checked((int)Math.Round(value, MidpointRounding.ToEven)); } private static int CheckedInt(long value) { if (value < int.MinValue || value > int.MaxValue) { throw new OverflowException("Environmental quantity exceeds the supported integer range."); } return (int)value; } } public sealed class WikiIndex { public const int MaximumRetainedDiagnostics = 256; private readonly IReadOnlyDictionary items; private readonly IReadOnlyList recipes; private readonly IReadOnlyList pieces; private readonly IReadOnlyList conversions; private readonly IReadOnlyList sources; private readonly IReadOnlyList diagnostics; private readonly IReadOnlyDictionary> recipesByOutput; private readonly IReadOnlyDictionary> recipesByMaterial; private readonly IReadOnlyDictionary> piecesByMaterial; private readonly IReadOnlyDictionary> conversionsByOutput; private readonly IReadOnlyDictionary> conversionsByMaterial; private readonly IReadOnlyDictionary> sourcesByOutput; public IReadOnlyDictionary Items => items; public IReadOnlyList Recipes => recipes; public IReadOnlyList Pieces => pieces; public IReadOnlyList Conversions => conversions; public IReadOnlyList Sources => sources; public IReadOnlyList Diagnostics => diagnostics; public int OmittedDiagnostics { get; } public DateTimeOffset BuiltAt { get; } public TimeSpan BuildDuration { get; } public string Language { get; } public int SkippedEntries { get; } public string CoverageNote { get; } public int UnavailableStatistics { get; } public bool WorldContextAvailable { get; } public WikiIndex(IEnumerable items, IEnumerable recipes, IEnumerable pieces, DateTimeOffset builtAt, TimeSpan buildDuration, string language, int skippedEntries, string coverageNote, int unavailableStatistics = 0, bool worldContextAvailable = false, IEnumerable? conversions = null, IEnumerable? sources = null, IEnumerable? diagnostics = null, int omittedDiagnostics = 0) { Dictionary dictionary = (items ?? throw new ArgumentNullException("items")).GroupBy((WikiItem item) => item.Id, StringComparer.OrdinalIgnoreCase).ToDictionary, string, WikiItem>((IGrouping group) => group.Key, (IGrouping group) => group.Last(), StringComparer.OrdinalIgnoreCase); WikiRecipe[] array = (recipes ?? throw new ArgumentNullException("recipes")).ToArray(); WikiPiece[] array2 = (pieces ?? throw new ArgumentNullException("pieces")).ToArray(); WikiConversion[] array3 = (conversions ?? Array.Empty()).ToArray(); WikiAcquisitionSource[] array4 = (sources ?? Array.Empty()).ToArray(); WikiDiagnosticIssue[] array5 = (diagnostics ?? Array.Empty()).OfType().ToArray(); WikiDiagnosticIssue[] array6 = array5.Take(256).ToArray(); this.items = new ReadOnlyDictionary(dictionary); this.recipes = Array.AsReadOnly(array); this.pieces = Array.AsReadOnly(array2); this.conversions = Array.AsReadOnly(array3); this.sources = Array.AsReadOnly(array4); this.diagnostics = Array.AsReadOnly(array6); recipesByOutput = Freeze(array.GroupBy((WikiRecipe recipe) => recipe.OutputItemId, StringComparer.OrdinalIgnoreCase)); recipesByMaterial = Freeze(array.SelectMany((WikiRecipe recipe) => recipe.Requirements.Select((WikiRequirement requirement) => new { ItemId = requirement.ItemId, Recipe = recipe })).GroupBy(value => value.ItemId, value => value.Recipe, StringComparer.OrdinalIgnoreCase)); piecesByMaterial = Freeze(array2.SelectMany((WikiPiece piece) => piece.Requirements.Select((WikiPieceRequirement requirement) => new { ItemId = requirement.ItemId, Piece = piece })).GroupBy(value => value.ItemId, value => value.Piece, StringComparer.OrdinalIgnoreCase)); conversionsByOutput = Freeze(array3.GroupBy((WikiConversion conversion) => conversion.OutputItemId, StringComparer.OrdinalIgnoreCase)); conversionsByMaterial = Freeze(array3.SelectMany((WikiConversion conversion) => conversion.Requirements.Select((WikiConversionRequirement requirement) => new { ItemId = requirement.ItemId, Conversion = conversion })).GroupBy(value => value.ItemId, value => value.Conversion, StringComparer.OrdinalIgnoreCase)); sourcesByOutput = Freeze(array4.GroupBy((WikiAcquisitionSource source) => source.OutputItemId, StringComparer.OrdinalIgnoreCase)); BuiltAt = builtAt; BuildDuration = buildDuration; Language = language ?? string.Empty; SkippedEntries = Math.Max(0, skippedEntries); CoverageNote = coverageNote ?? string.Empty; UnavailableStatistics = Math.Max(0, unavailableStatistics); WorldContextAvailable = worldContextAvailable; OmittedDiagnostics = Math.Max(0, omittedDiagnostics) + Math.Max(0, array5.Length - array6.Length); } public IReadOnlyList RecipesForOutput(string itemId) { return Lookup(recipesByOutput, itemId); } public IReadOnlyList RecipesUsing(string itemId) { return Lookup(recipesByMaterial, itemId); } public IReadOnlyList PiecesUsing(string itemId) { return Lookup(piecesByMaterial, itemId); } public IReadOnlyList ConversionsForOutput(string itemId) { return Lookup(conversionsByOutput, itemId); } public IReadOnlyList ConversionsUsing(string itemId) { return Lookup(conversionsByMaterial, itemId); } public IReadOnlyList SourcesForOutput(string itemId) { return Lookup(sourcesByOutput, itemId); } private static IReadOnlyDictionary> Freeze(IEnumerable> groups) { return new ReadOnlyDictionary>(groups.ToDictionary, string, IReadOnlyList>((IGrouping group) => group.Key, (IGrouping group) => Array.AsReadOnly(group.Distinct().ToArray()), StringComparer.OrdinalIgnoreCase)); } private static IReadOnlyList Lookup(IReadOnlyDictionary> map, string key) { if (string.IsNullOrWhiteSpace(key) || !map.TryGetValue(key, out IReadOnlyList value)) { return Array.Empty(); } return value; } } } namespace MimirsWell.Core.Formatting { public sealed class ChatPage { public IReadOnlyList Lines { get; } public bool HasMore { get; } public ChatPage(IEnumerable lines, bool hasMore) { Lines = Array.AsReadOnly((lines ?? throw new ArgumentNullException("lines")).ToArray()); HasMore = hasMore; } } public sealed class ChatPaginator { private IReadOnlyList> pages = Array.Empty>(); private int nextPageIndex; private DateTimeOffset expiresAt; public ChatPage Begin(WikiAnswer answer, int maximumLines, TimeSpan lifetime, DateTimeOffset now, int maximumResults = int.MaxValue) { if (answer == null) { throw new ArgumentNullException("answer"); } List list = new List(); if (!string.IsNullOrWhiteSpace(answer.Title)) { list.Add(answer.Title); } list.AddRange(answer.Lines.Where((string line) => !string.IsNullOrWhiteSpace(line))); list.AddRange(from line in answer.Warnings where !string.IsNullOrWhiteSpace(line) select "Note: " + line); list.AddRange(from line in answer.Suggestions where !string.IsNullOrWhiteSpace(line) select "Tip: " + line); pages = BuildPages(list, maximumLines, maximumResults); nextPageIndex = 0; expiresAt = now.Add(lifetime); return NextPage(); } public ChatPage More(int maximumLines, DateTimeOffset now, int maximumResults = int.MaxValue) { if (now > expiresAt || nextPageIndex >= pages.Count) { Clear(); return new ChatPage(new string[1] { "No more results are available; run another wiki query first." }, hasMore: false); } return NextPage(); } public void Clear() { pages = Array.Empty>(); nextPageIndex = 0; expiresAt = default(DateTimeOffset); } private ChatPage NextPage() { if (nextPageIndex >= pages.Count) { return new ChatPage(new string[1] { "No more results are available; run another wiki query first." }, hasMore: false); } int num = nextPageIndex + 1; List list = pages[nextPageIndex].ToList(); nextPageIndex++; bool flag = nextPageIndex < pages.Count; if (pages.Count > 1) { list.Add(flag ? $"Page {num} of {pages.Count}. Use /wiki more for the next page." : $"Page {num} of {pages.Count}."); } return new ChatPage(list, flag); } private static IReadOnlyList> BuildPages(IEnumerable source, int maximumLines, int maximumResults) { string[] array = source.ToArray(); int num = Math.Max(2, maximumLines); int num2 = Math.Max(1, maximumResults); if (array.Length <= num && array.Count(IsResultRow) <= num2) { return Array.AsReadOnly(new IReadOnlyList[1] { Array.AsReadOnly(array) }); } int num3 = Math.Max(1, num - 1); int i = 0; List> list = new List>(); while (i < array.Length) { List list2 = new List(); int num4 = 0; for (; i < array.Length; i++) { if (list2.Count >= num3) { break; } bool flag = IsResultRow(array[i]); if (flag && num4 >= num2) { break; } list2.Add(array[i]); if (flag) { num4++; } } if (list2.Count == 0) { list2.Add(array[i]); i++; } list.Add(Array.AsReadOnly(list2.ToArray())); } if (list.Count == 0) { list.Add(Array.AsReadOnly(Array.Empty())); } return Array.AsReadOnly(list.ToArray()); } private static bool IsResultRow(string value) { if (value.StartsWith("- ", StringComparison.Ordinal)) { return true; } int num = value.IndexOf('.'); if (num > 0 && num <= 3) { return value.Substring(0, num).All(char.IsDigit); } return false; } } } namespace MimirsWell.Core.Domain { public enum WikiDiagnosticKind { SkippedEntry, UnavailableStatistic } public sealed class WikiDiagnosticIssue { public WikiDiagnosticKind Kind { get; } public string Category { get; } public string EntityId { get; } public string? RelatedItemId { get; } public string ReasonCode { get; } public WikiDiagnosticIssue(WikiDiagnosticKind kind, string category, string entityId, string? relatedItemId, string reasonCode) { Kind = kind; Category = Clean(category, "unknown", 80); EntityId = Clean(entityId, "", 160); RelatedItemId = (string.IsNullOrWhiteSpace(relatedItemId) ? null : relatedItemId); ReasonCode = Clean(reasonCode, "Unavailable", 80); } private static string Clean(string value, string fallback, int maximumLength) { if (string.IsNullOrWhiteSpace(value)) { return fallback; } string text = new string(value.Where((char character) => !char.IsControl(character)).Take(maximumLength).ToArray()).Trim(); if (text.Length != 0) { return text; } return fallback; } } public enum WikiIntent { Unsupported, Help, ItemInfo, RecipeLookup, UpgradeLookup, MaterialUsage, CraftingPlan, AcquisitionSource, SourceInventory, ItemComparison, CategoryRanking, ComparisonExplanation, StrongerAlternatives, Find, CraftingStation, ContextHeld, ContextEquipped, ContextFood, ContextStation, Diagnostics, ModReport, UnsupportedReport, More, Status, Reindex, Reload } public enum WikiItemKind { Unknown, Weapon, Shield, Armor, Food, Tool, Material, Consumable, Miscellaneous } public enum DamageKind { Blunt, Slash, Pierce, Fire, Frost, Lightning, Poison, Spirit, Chop, Pickaxe } public enum ResolutionStatus { Resolved, Ambiguous, NotFound } public enum WikiProcessKind { Processing, Cooking, Fermenting, Incinerating } public enum WikiSourceKind { CreatureDrop, Purchased, PassivelyProduced, Harvested, MinedOrDestroyed, Fishing } public enum WikiSourceCertainty { Guaranteed, Chance, Conditional } public enum WikiQuantitySemantics { Fixed, ExactLoadedRange } public sealed class WikiDamageProfile { private readonly IReadOnlyDictionary values; public IReadOnlyDictionary Values => values; public double ListedCombatTotal => values.Where((KeyValuePair pair) => pair.Key != DamageKind.Chop && pair.Key != DamageKind.Pickaxe).Sum((KeyValuePair pair) => pair.Value); public WikiDamageProfile(IEnumerable> values) { if (values == null) { throw new ArgumentNullException("values"); } this.values = new ReadOnlyDictionary((from pair in values group pair by pair.Key).ToDictionary((IGrouping> group) => group.Key, (IGrouping> group) => group.Last().Value)); } public bool HasPositive(DamageKind kind) { if (values.TryGetValue(kind, out var value)) { return value > 0.0; } return false; } public double Get(DamageKind kind) { if (!values.TryGetValue(kind, out var value)) { return 0.0; } return value; } } public sealed class WikiQualityStats { public int Quality { get; } public WikiDamageProfile? Damage { get; } public double? Armor { get; } public double? Durability { get; } public double? Block { get; } public double? Deflection { get; } public double? PrimaryAttackStamina { get; } public double? SecondaryAttackStamina { get; } public WikiQualityStats(int quality, WikiDamageProfile? damage, double? armor, double? durability, double? block, double? deflection, double? primaryAttackStamina, double? secondaryAttackStamina) { if (quality < 1) { throw new ArgumentOutOfRangeException("quality"); } Quality = quality; Damage = damage; Armor = armor; Durability = durability; Block = block; Deflection = deflection; PrimaryAttackStamina = primaryAttackStamina; SecondaryAttackStamina = secondaryAttackStamina; } } public sealed class WikiItem { private readonly IReadOnlyList aliases; private readonly IReadOnlyDictionary qualities; public string Id { get; } public string InternalName { get; } public string DisplayName { get; } public string Description { get; } public WikiItemKind Kind { get; } public string Category { get; } public IReadOnlyList Aliases => aliases; public string? SourceMod { get; } public int MaxStack { get; } public double Weight { get; } public int MaxQuality { get; } public double MovementModifier { get; } public double? FoodHealth { get; } public double? FoodStamina { get; } public double? FoodEitr { get; } public double? FoodDurationSeconds { get; } public double? FoodRegeneration { get; } public double? ParryBonus { get; } public string KnowledgeKey { get; } public IReadOnlyDictionary Qualities => qualities; public WikiItem(string id, string internalName, string displayName, string description, WikiItemKind kind, string category, IEnumerable? aliases, string? sourceMod, int maxStack, double weight, int maxQuality, double movementModifier, double? foodHealth, double? foodStamina, double? foodEitr, double? foodDurationSeconds, double? foodRegeneration, double? parryBonus, IEnumerable qualities, string? knowledgeKey = null) { Id = Require(id, "id"); InternalName = Require(internalName, "internalName"); DisplayName = Require(displayName, "displayName"); Description = description ?? string.Empty; Kind = kind; Category = Require(category, "category"); this.aliases = Array.AsReadOnly((aliases ?? Array.Empty()).Where((string value) => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); SourceMod = (string.IsNullOrWhiteSpace(sourceMod) ? null : sourceMod); MaxStack = Math.Max(1, maxStack); Weight = weight; MaxQuality = Math.Max(1, maxQuality); MovementModifier = movementModifier; FoodHealth = foodHealth; FoodStamina = foodStamina; FoodEitr = foodEitr; FoodDurationSeconds = foodDurationSeconds; FoodRegeneration = foodRegeneration; ParryBonus = parryBonus; KnowledgeKey = (string.IsNullOrWhiteSpace(knowledgeKey) ? InternalName : knowledgeKey); this.qualities = new ReadOnlyDictionary((from value in qualities ?? throw new ArgumentNullException("qualities") group value by value.Quality).ToDictionary((IGrouping group) => group.Key, (IGrouping group) => group.Last())); } public WikiQualityStats StatsAt(int quality) { int key = Math.Max(1, Math.Min(MaxQuality, quality)); if (qualities.TryGetValue(key, out WikiQualityStats value)) { return value; } if (qualities.TryGetValue(1, out WikiQualityStats value2)) { return value2; } throw new InvalidOperationException("Item '" + Id + "' has no quality statistics."); } private static string Require(string value, string parameter) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("A non-empty value is required.", parameter); } return value; } } public sealed class WikiRequirement { private readonly IReadOnlyDictionary amountsByTargetQuality; public string ItemId { get; } public string ItemName { get; } public IReadOnlyDictionary AmountsByTargetQuality => amountsByTargetQuality; public WikiRequirement(string itemId, string itemName, IEnumerable> amountsByTargetQuality) { if (string.IsNullOrWhiteSpace(itemId)) { throw new ArgumentException("Required.", "itemId"); } ItemId = itemId; ItemName = (string.IsNullOrWhiteSpace(itemName) ? itemId : itemName); this.amountsByTargetQuality = new ReadOnlyDictionary((from pair in amountsByTargetQuality ?? throw new ArgumentNullException("amountsByTargetQuality") where pair.Key >= 1 group pair by pair.Key).ToDictionary((IGrouping> group) => group.Key, (IGrouping> group) => Math.Max(0, group.Last().Value))); } public int AmountForTargetQuality(int quality) { if (!amountsByTargetQuality.TryGetValue(quality, out var value)) { return 0; } return value; } } public sealed class WikiRecipe { private readonly IReadOnlyList requirements; private readonly IReadOnlyDictionary stationLevels; public string Id { get; } public string OutputItemId { get; } public string OutputName { get; } public int OutputAmount { get; } public string? StationName { get; } public IReadOnlyDictionary StationLevels => stationLevels; public bool RequireOnlyOneIngredient { get; } public IReadOnlyList Requirements => requirements; public WikiRecipe(string id, string outputItemId, string outputName, int outputAmount, string? stationName, IEnumerable> stationLevels, bool requireOnlyOneIngredient, IEnumerable requirements) { if (string.IsNullOrWhiteSpace(id)) { throw new ArgumentException("Required.", "id"); } Id = id; if (string.IsNullOrWhiteSpace(outputItemId)) { throw new ArgumentException("Required.", "outputItemId"); } OutputItemId = outputItemId; OutputName = (string.IsNullOrWhiteSpace(outputName) ? outputItemId : outputName); OutputAmount = Math.Max(1, outputAmount); StationName = (string.IsNullOrWhiteSpace(stationName) ? null : stationName); this.stationLevels = new ReadOnlyDictionary((from pair in stationLevels ?? throw new ArgumentNullException("stationLevels") where pair.Key >= 1 group pair by pair.Key).ToDictionary((IGrouping> group) => group.Key, (IGrouping> group) => Math.Max(1, group.Last().Value))); RequireOnlyOneIngredient = requireOnlyOneIngredient; this.requirements = Array.AsReadOnly((requirements ?? throw new ArgumentNullException("requirements")).ToArray()); } public int StationLevelForTargetQuality(int quality) { if (!stationLevels.TryGetValue(quality, out var value)) { return 1; } return value; } } public sealed class WikiPieceRequirement { public string ItemId { get; } public string ItemName { get; } public int Amount { get; } public WikiPieceRequirement(string itemId, string itemName, int amount) { if (string.IsNullOrWhiteSpace(itemId)) { throw new ArgumentException("Required.", "itemId"); } ItemId = itemId; ItemName = (string.IsNullOrWhiteSpace(itemName) ? itemId : itemName); Amount = Math.Max(0, amount); } } public sealed class WikiPiece { private readonly IReadOnlyList requirements; public string Id { get; } public string DisplayName { get; } public string Category { get; } public string PieceTable { get; } public string? StationName { get; } public IReadOnlyList Requirements => requirements; public string KnowledgeKey { get; } public WikiPiece(string id, string displayName, string category, string pieceTable, string? stationName, IEnumerable requirements, string? knowledgeKey = null) { if (string.IsNullOrWhiteSpace(id)) { throw new ArgumentException("Required.", "id"); } Id = id; DisplayName = (string.IsNullOrWhiteSpace(displayName) ? id : displayName); Category = category ?? string.Empty; PieceTable = pieceTable ?? string.Empty; StationName = (string.IsNullOrWhiteSpace(stationName) ? null : stationName); this.requirements = Array.AsReadOnly((requirements ?? throw new ArgumentNullException("requirements")).ToArray()); KnowledgeKey = (string.IsNullOrWhiteSpace(knowledgeKey) ? Id : knowledgeKey); } } public sealed class WikiConversionRequirement { public string ItemId { get; } public string ItemName { get; } public double? Amount { get; } public string Role { get; } public WikiConversionRequirement(string itemId, string itemName, double? amount, string role) { if (string.IsNullOrWhiteSpace(itemId)) { throw new ArgumentException("Required.", "itemId"); } ItemId = itemId; ItemName = (string.IsNullOrWhiteSpace(itemName) ? itemId : itemName); Amount = (amount.HasValue ? new double?(Math.Max(0.0, amount.Value)) : ((double?)null)); Role = (string.IsNullOrWhiteSpace(role) ? "Input" : role); } } public sealed class WikiConversion { private readonly IReadOnlyList requirements; public string Id { get; } public WikiProcessKind Kind { get; } public string StationInternalName { get; } public string StationName { get; } public string OutputItemId { get; } public string OutputName { get; } public int OutputAmount { get; } public IReadOnlyList Requirements => requirements; public double? DurationSeconds { get; } public bool RequireOnlyOneIngredient { get; } public WikiConversion(string id, WikiProcessKind kind, string stationInternalName, string stationName, string outputItemId, string outputName, int outputAmount, IEnumerable requirements, double? durationSeconds, bool requireOnlyOneIngredient) { if (string.IsNullOrWhiteSpace(id)) { throw new ArgumentException("Required.", "id"); } Id = id; Kind = kind; StationInternalName = (string.IsNullOrWhiteSpace(stationInternalName) ? stationName : stationInternalName); StationName = (string.IsNullOrWhiteSpace(stationName) ? stationInternalName : stationName); if (string.IsNullOrWhiteSpace(outputItemId)) { throw new ArgumentException("Required.", "outputItemId"); } OutputItemId = outputItemId; OutputName = (string.IsNullOrWhiteSpace(outputName) ? outputItemId : outputName); OutputAmount = Math.Max(1, outputAmount); this.requirements = Array.AsReadOnly((requirements ?? throw new ArgumentNullException("requirements")).ToArray()); DurationSeconds = (durationSeconds.HasValue ? new double?(Math.Max(0.0, durationSeconds.Value)) : ((double?)null)); RequireOnlyOneIngredient = requireOnlyOneIngredient; } } public sealed class WikiAcquisitionSource { public string Id { get; } public WikiSourceKind Kind { get; } public WikiSourceCertainty Certainty { get; } public string Adapter { get; } public string SourceInternalName { get; } public string SourceName { get; } public string? SourceMod { get; } public string OutputItemId { get; } public string OutputName { get; } public int MinimumAmount { get; } public int MaximumAmount { get; } public double? Chance { get; } public string? CostItemId { get; } public string? CostItemName { get; } public int? Price { get; } public string? UnlockRequirement { get; } public double? ProductionIntervalSeconds { get; } public int? Capacity { get; } public double? RespawnSeconds { get; } public bool OnePerPlayer { get; } public bool LevelScaled { get; } public bool WorldScaled { get; } public string? Method { get; } public int? MinimumToolTier { get; } public WikiQuantitySemantics QuantitySemantics { get; } public int? ConfiguredMinimumAmount { get; } public int? ConfiguredMaximumAmount { get; } public double? LoadedResourceRate { get; } public WikiAcquisitionSource(string id, WikiSourceKind kind, WikiSourceCertainty certainty, string adapter, string sourceInternalName, string sourceName, string? sourceMod, string outputItemId, string outputName, int minimumAmount, int maximumAmount, double? chance = null, string? costItemId = null, string? costItemName = null, int? price = null, string? unlockRequirement = null, double? productionIntervalSeconds = null, int? capacity = null, double? respawnSeconds = null, bool onePerPlayer = false, bool levelScaled = false, bool worldScaled = false, string? method = null, int? minimumToolTier = null, WikiQuantitySemantics quantitySemantics = WikiQuantitySemantics.Fixed, int? configuredMinimumAmount = null, int? configuredMaximumAmount = null, double? loadedResourceRate = null) { Id = Require(id, "id"); Kind = kind; Certainty = certainty; Adapter = Require(adapter, "adapter"); SourceInternalName = Require(sourceInternalName, "sourceInternalName"); SourceName = Require(sourceName, "sourceName"); SourceMod = (string.IsNullOrWhiteSpace(sourceMod) ? null : sourceMod); OutputItemId = Require(outputItemId, "outputItemId"); OutputName = Require(outputName, "outputName"); if (minimumAmount < 0) { throw new ArgumentOutOfRangeException("minimumAmount"); } if (maximumAmount < minimumAmount) { throw new ArgumentOutOfRangeException("maximumAmount"); } if (chance.HasValue && (chance.Value < 0.0 || chance.Value > 1.0)) { throw new ArgumentOutOfRangeException("chance"); } if (certainty == WikiSourceCertainty.Chance && !chance.HasValue) { throw new ArgumentException("Chance certainty requires a loaded probability.", "chance"); } if (price.HasValue && price.Value < 0) { throw new ArgumentOutOfRangeException("price"); } if (productionIntervalSeconds.HasValue && productionIntervalSeconds.Value < 0.0) { throw new ArgumentOutOfRangeException("productionIntervalSeconds"); } if (capacity.HasValue && capacity.Value < 0) { throw new ArgumentOutOfRangeException("capacity"); } if (respawnSeconds.HasValue && respawnSeconds.Value < 0.0) { throw new ArgumentOutOfRangeException("respawnSeconds"); } if (minimumToolTier.HasValue && minimumToolTier.Value < 0) { throw new ArgumentOutOfRangeException("minimumToolTier"); } if (configuredMinimumAmount.HasValue && configuredMinimumAmount.Value < 0) { throw new ArgumentOutOfRangeException("configuredMinimumAmount"); } if (configuredMaximumAmount.HasValue && (!configuredMinimumAmount.HasValue || configuredMaximumAmount.Value < configuredMinimumAmount.Value)) { throw new ArgumentOutOfRangeException("configuredMaximumAmount"); } if (loadedResourceRate.HasValue && (double.IsNaN(loadedResourceRate.Value) || double.IsInfinity(loadedResourceRate.Value) || loadedResourceRate.Value <= 0.0)) { throw new ArgumentOutOfRangeException("loadedResourceRate"); } if (quantitySemantics == WikiQuantitySemantics.ExactLoadedRange && certainty != WikiSourceCertainty.Guaranteed) { throw new ArgumentException("Exact loaded quantity ranges are currently supported only for guaranteed sources.", "quantitySemantics"); } MinimumAmount = minimumAmount; MaximumAmount = maximumAmount; Chance = chance; CostItemId = (string.IsNullOrWhiteSpace(costItemId) ? null : costItemId); CostItemName = (string.IsNullOrWhiteSpace(costItemName) ? CostItemId : costItemName); Price = price; UnlockRequirement = (string.IsNullOrWhiteSpace(unlockRequirement) ? null : unlockRequirement); ProductionIntervalSeconds = productionIntervalSeconds; Capacity = capacity; RespawnSeconds = respawnSeconds; OnePerPlayer = onePerPlayer; LevelScaled = levelScaled; WorldScaled = worldScaled; Method = (string.IsNullOrWhiteSpace(method) ? null : method); MinimumToolTier = minimumToolTier; QuantitySemantics = quantitySemantics; ConfiguredMinimumAmount = configuredMinimumAmount; ConfiguredMaximumAmount = configuredMaximumAmount; LoadedResourceRate = loadedResourceRate; } private static string Require(string value, string parameter) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("A non-empty value is required.", parameter); } return value; } } public sealed class WikiQuery { private readonly IReadOnlyList entityTexts; private readonly IReadOnlyDictionary filters; public WikiIntent Intent { get; } public IReadOnlyList EntityTexts => entityTexts; public IReadOnlyDictionary Filters => filters; public int? Quality { get; } public double Confidence { get; } public string InterpretationSource { get; } public string Diagnostic { get; } public long? Quantity { get; } public WikiQuery(WikiIntent intent, IEnumerable? entityTexts, IDictionary? filters, int? quality, double confidence, string interpretationSource, string diagnostic, long? quantity = null) { Intent = intent; this.entityTexts = Array.AsReadOnly((entityTexts ?? Array.Empty()).ToArray()); this.filters = new ReadOnlyDictionary(new Dictionary(filters ?? new Dictionary(), StringComparer.OrdinalIgnoreCase)); Quality = quality; Confidence = Math.Max(0.0, Math.Min(1.0, confidence)); InterpretationSource = interpretationSource ?? string.Empty; Diagnostic = diagnostic ?? string.Empty; Quantity = quantity; } } public sealed class WikiAnswer { private readonly IReadOnlyList lines; private readonly IReadOnlyList warnings; private readonly IReadOnlyList suggestions; public string Title { get; } public IReadOnlyList Lines => lines; public IReadOnlyList Warnings => warnings; public IReadOnlyList Suggestions => suggestions; public bool IsPaginated { get; } public WikiAnswer(string title, IEnumerable? lines = null, IEnumerable? warnings = null, IEnumerable? suggestions = null, bool isPaginated = false) { Title = title ?? string.Empty; this.lines = Array.AsReadOnly((lines ?? Array.Empty()).ToArray()); this.warnings = Array.AsReadOnly((warnings ?? Array.Empty()).ToArray()); this.suggestions = Array.AsReadOnly((suggestions ?? Array.Empty()).ToArray()); IsPaginated = isPaginated; } } public sealed class ResolutionCandidate { public WikiItem Item { get; } public double Score { get; } public string Reason { get; } public ResolutionCandidate(WikiItem item, double score, string reason) { Item = item ?? throw new ArgumentNullException("item"); Score = score; Reason = reason ?? string.Empty; } } public sealed class ResolutionResult { private readonly IReadOnlyList candidates; public ResolutionStatus Status { get; } public IReadOnlyList Candidates => candidates; public string Diagnostic { get; } public WikiItem? Item { get { if (Status != ResolutionStatus.Resolved || candidates.Count <= 0) { return null; } return candidates[0].Item; } } public ResolutionResult(ResolutionStatus status, IEnumerable? candidates, string diagnostic) { Status = status; this.candidates = Array.AsReadOnly((candidates ?? Array.Empty()).ToArray()); Diagnostic = diagnostic ?? string.Empty; } } }