Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of ConcernedCartographer v1.3.1
plugins/TheConcernedCat.ConcernedCartographer.dll
Decompiled 2 weeks ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Net; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; using System.Threading; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Jotunn; using Jotunn.Entities; using Jotunn.Managers; using Microsoft.CodeAnalysis; using Splatform; using TheConcernedCat.Companions.Definitions; using TheConcernedCat.Companions.Dialogue; using TheConcernedCat.Companions.Identity; using TheConcernedCat.Companions.Persistence; using TheConcernedCat.Companions.Placement; using TheConcernedCat.Companions.Quest; using TheConcernedCat.Companions.Session; using TheConcernedCat.Companions.Surroundings; using TheConcernedCat.Companions.Unlock; using TheConcernedCat.ConcernedCartographer.Atlas; using TheConcernedCat.ConcernedCartographer.Companions; using TheConcernedCat.ConcernedCartographer.Map; using TheConcernedCat.ConcernedCartographer.Persistence; using TheConcernedCat.ConcernedCartographer.Reporting; using TheConcernedCat.ConcernedCartographer.Roads; using TheConcernedCat.ConcernedCartographer.Runtime; using TheConcernedCat.ConcernedCartographer.Runtime.Companions; using TheConcernedCat.ConcernedCartographer.Runtime.Interop; using TheConcernedCat.ConcernedCartographer.Storage; using TheConcernedCat.ConcernedCartographer.Ui; using TheConcernedCat.Interop; using TheConcernedCat.Interop.Presence; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.Events; using UnityEngine.UI; [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("The Concerned Cat")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyCopyright("Copyright © 2026 Eren Cansunar")] [assembly: AssemblyFileVersion("1.3.1.0")] [assembly: AssemblyInformationalVersion("1.3.1+c854fa405cd51da2f5117802f76190c5351b9ccd")] [assembly: AssemblyProduct("Concerned Cartographer")] [assembly: AssemblyTitle("TheConcernedCat.ConcernedCartographer")] [assembly: AssemblyMetadata("RepositoryUrl", "https://github.com/Weakened/ConcernedCatMods")] [assembly: AssemblyVersion("1.3.1.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 TheConcernedCat.Interop { internal static class CapabilityMap { public const string PropertyName = "ConcernedCatCapabilities"; public static string KeyFor(string contractId, int major) { if (string.IsNullOrEmpty(contractId)) { throw new ArgumentException("A contract id is required.", "contractId"); } if (major < 1) { throw new ArgumentOutOfRangeException("major", "Contract majors start at 1."); } return contractId + "/" + major.ToString(CultureInfo.InvariantCulture); } public static bool TryGetEndpoint(IReadOnlyDictionary<string, object>? map, string contractId, int major, out Func<IReadOnlyDictionary<string, string>, IReadOnlyDictionary<string, string>>? endpoint) { endpoint = null; if (map == null || !map.TryGetValue(KeyFor(contractId, major), out object value)) { return false; } endpoint = value as Func<IReadOnlyDictionary<string, string>, IReadOnlyDictionary<string, string>>; return endpoint != null; } public static IReadOnlyDictionary<string, string>? TryCall(Func<IReadOnlyDictionary<string, string>, IReadOnlyDictionary<string, string>>? endpoint, IReadOnlyDictionary<string, string> request) { if (endpoint == null) { return null; } try { return endpoint(request); } catch (Exception) { return null; } } } internal enum CapabilityStatus { Unspecified, Available, Absent, VersionTooLow, ProbeFailed, MajorMismatch } internal sealed class WireMessage { private readonly Dictionary<string, string> _fields; public int Count => _fields.Count; private WireMessage(Dictionary<string, string> fields) { _fields = fields; } public static WireMessage Create() { return new WireMessage(new Dictionary<string, string>(StringComparer.Ordinal)); } public static WireMessage From(IReadOnlyDictionary<string, string>? wire) { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal); if (wire != null) { foreach (KeyValuePair<string, string> item in wire) { if (item.Key != null && item.Value != null) { dictionary[item.Key] = item.Value; } } } return new WireMessage(dictionary); } public IReadOnlyDictionary<string, string> ToWire() { return new Dictionary<string, string>(_fields, StringComparer.Ordinal); } public WireMessage Set(string key, string value) { if (string.IsNullOrEmpty(key)) { throw new ArgumentException("A field key is required.", "key"); } _fields[key] = value ?? throw new ArgumentNullException("value"); return this; } public WireMessage SetInt(string key, int value) { return Set(key, value.ToString(CultureInfo.InvariantCulture)); } public WireMessage SetBool(string key, bool value) { return Set(key, value ? "true" : "false"); } public WireMessage SetEnum<T>(string key, T value) where T : struct, Enum { if (!Enum.IsDefined(typeof(T), value)) { throw new ArgumentOutOfRangeException("value", "Only named values travel."); } return Set(key, value.ToString()); } public WireMessage SetPoint(string key, float x, float y, float z) { return Set(key, x.ToString("R", CultureInfo.InvariantCulture) + ";" + y.ToString("R", CultureInfo.InvariantCulture) + ";" + z.ToString("R", CultureInfo.InvariantCulture)); } public bool Has(string key) { return _fields.ContainsKey(key); } public bool TryGet(string key, out string value) { if (_fields.TryGetValue(key, out string value2) && value2 != null) { value = value2; return true; } value = string.Empty; return false; } public bool TryGetInt(string key, out int value) { value = 0; if (TryGet(key, out string value2)) { return int.TryParse(value2, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } return false; } public bool TryGetBool(string key, out bool value) { value = false; if (!TryGet(key, out string value2)) { return false; } if (string.Equals(value2, "true", StringComparison.Ordinal)) { value = true; return true; } return string.Equals(value2, "false", StringComparison.Ordinal); } public bool TryGetEnum<T>(string key, out T value) where T : struct, Enum { value = default(T); if (!TryGet(key, out string value2) || value2.Length == 0 || !IsName(value2)) { return false; } string[] names = Enum.GetNames(typeof(T)); foreach (string text in names) { if (string.Equals(text, value2, StringComparison.Ordinal)) { value = (T)Enum.Parse(typeof(T), text); return true; } } return false; } public bool TryGetPoint(string key, out float x, out float y, out float z) { x = (y = (z = 0f)); if (!TryGet(key, out string value)) { return false; } string[] array = value.Split(new char[1] { ';' }); if (array.Length == 3 && TryParseFinite(array[0], out x) && TryParseFinite(array[1], out y)) { return TryParseFinite(array[2], out z); } return false; } private static bool IsName(string text) { if (!char.IsLetter(text[0])) { return false; } for (int i = 0; i < text.Length; i++) { if (!char.IsLetterOrDigit(text[i])) { return false; } } return true; } private static bool TryParseFinite(string text, out float value) { if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && !float.IsNaN(value)) { return !float.IsInfinity(value); } return false; } } } namespace TheConcernedCat.Interop.Presence { internal static class PresenceContract { internal static class Companions { public const string Hulgi = "hulgi"; } internal static class Ops { public const string Hello = "hello"; public const string DescribeCompanion = "describeCompanion"; } internal static class Keys { public const string Op = "op"; public const string ContractMajor = "contractMajor"; public const string ContractMinor = "contractMinor"; public const string ConsumerVersion = "consumerVersion"; public const string ProviderVersion = "providerVersion"; public const string Status = "status"; public const string Reason = "reason"; public const string Detail = "detail"; public const string CompanionId = "companionId"; public const string Known = "known"; public const string Present = "present"; public const string Visible = "visible"; public const string Available = "available"; public const string Position = "position"; } internal static class Statuses { public const string Ok = "ok"; public const string Unsupported = "unsupported"; public const string Unavailable = "unavailable"; public const string BadRequest = "badRequest"; public const string ProviderError = "providerError"; } public const string Id = "concernedcat.presence"; public const int Major = 1; public const int Minor = 0; public const string ProviderGuid = "com.theconcernedcat.valheim.concernedcartographer"; public static string Bool(bool value) { if (!value) { return "false"; } return "true"; } public static bool ReadBool(IReadOnlyDictionary<string, string>? reply, string key) { if (reply != null && reply.TryGetValue(key, out string value)) { return string.Equals(value, "true", StringComparison.Ordinal); } return false; } public static string? Read(IReadOnlyDictionary<string, string>? reply, string key) { if (reply == null || !reply.TryGetValue(key, out string value)) { return null; } return value; } public static string Point(float x, float y, float z) { return x.ToString("R", CultureInfo.InvariantCulture) + ";" + y.ToString("R", CultureInfo.InvariantCulture) + ";" + z.ToString("R", CultureInfo.InvariantCulture); } public static bool TryPoint(string? raw, out float x, out float y, out float z) { x = 0f; y = 0f; z = 0f; if (string.IsNullOrEmpty(raw)) { return false; } string[] array = raw.Split(new char[1] { ';' }); if (array.Length == 3 && float.TryParse(array[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x) && float.TryParse(array[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y)) { return float.TryParse(array[2], NumberStyles.Float, CultureInfo.InvariantCulture, out z); } return false; } public static IReadOnlyDictionary<string, string> Request(string op, string? companionId, string consumerVersion) { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal) { ["op"] = op ?? string.Empty, ["contractMajor"] = 1.ToString(CultureInfo.InvariantCulture), ["contractMinor"] = 0.ToString(CultureInfo.InvariantCulture), ["consumerVersion"] = consumerVersion ?? "unknown" }; if (!string.IsNullOrEmpty(companionId)) { dictionary["companionId"] = companionId; } return dictionary; } public static IReadOnlyDictionary<string, string> Reply(string status, string providerVersion, string? reason = null) { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal) { ["status"] = status, ["contractMajor"] = 1.ToString(CultureInfo.InvariantCulture), ["contractMinor"] = 0.ToString(CultureInfo.InvariantCulture), ["providerVersion"] = providerVersion ?? "unknown" }; if (!string.IsNullOrEmpty(reason)) { dictionary["reason"] = reason; } return dictionary; } } internal readonly struct PresenceFacts { public bool Known { get; } public bool Present { get; } public bool Visible { get; } public bool Available { get; } public bool HasPosition { get; } public float X { get; } public float Y { get; } public float Z { get; } public static PresenceFacts Nobody => new PresenceFacts(known: false, present: false, visible: false, available: false); public PresenceFacts(bool known, bool present, bool visible, bool available) : this(known, present, visible, available, hasPosition: false, 0f, 0f, 0f) { } public PresenceFacts(bool known, bool present, bool visible, bool available, bool hasPosition, float x, float y, float z) { Known = known; Present = present; Visible = visible; Available = available; HasPosition = hasPosition; X = x; Y = y; Z = z; } } internal static class PresenceResponder { private static string MajorText => 1.ToString(CultureInfo.InvariantCulture); public static IReadOnlyDictionary<string, string> Answer(IReadOnlyDictionary<string, string>? request, string providerVersion, bool ready, Func<string, PresenceFacts> facts) { if (facts == null) { throw new ArgumentNullException("facts"); } string providerVersion2 = (string.IsNullOrEmpty(providerVersion) ? "unknown" : providerVersion); if (request == null || !request.TryGetValue("op", out string value) || string.IsNullOrEmpty(value)) { return PresenceContract.Reply("badRequest", providerVersion2, "no op"); } if (request.TryGetValue("contractMajor", out string value2) && !string.Equals(value2, MajorText, StringComparison.Ordinal)) { return PresenceContract.Reply("unsupported", providerVersion2, "this provider speaks major " + MajorText); } if (string.Equals(value, "hello", StringComparison.Ordinal)) { return PresenceContract.Reply("ok", providerVersion2); } if (!string.Equals(value, "describeCompanion", StringComparison.Ordinal)) { return PresenceContract.Reply("unsupported", providerVersion2, value); } if (!request.TryGetValue("companionId", out string value3) || string.IsNullOrEmpty(value3)) { return PresenceContract.Reply("badRequest", providerVersion2, "no companion id"); } if (!ready) { return PresenceContract.Reply("unavailable", providerVersion2, "no runtime"); } return Describe(value3, facts(value3), providerVersion2); } public static IReadOnlyDictionary<string, string> Describe(string companionId, PresenceFacts facts, string providerVersion) { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal) { ["status"] = "ok", ["contractMajor"] = MajorText, ["contractMinor"] = 0.ToString(CultureInfo.InvariantCulture), ["providerVersion"] = (string.IsNullOrEmpty(providerVersion) ? "unknown" : providerVersion), ["companionId"] = companionId ?? string.Empty, ["known"] = PresenceContract.Bool(facts.Known), ["present"] = PresenceContract.Bool(facts.Present), ["visible"] = PresenceContract.Bool(facts.Visible), ["available"] = PresenceContract.Bool(facts.Available) }; if (facts.HasPosition) { dictionary["position"] = PresenceContract.Point(facts.X, facts.Y, facts.Z); } return dictionary; } } } namespace TheConcernedCat.Companions.Unlock { internal enum LegacyEvidence { None, Ambiguous, Present } internal readonly struct UnlockDecision { public bool IsUnlocked { get; } public UnlockReason Reason { get; } public bool ShouldPersistGrant { get; } public bool ShouldPresentIntroduction { get; } public bool ShouldOfferToolsOnly { get; } public UnlockDecision(bool isUnlocked, UnlockReason reason, bool shouldPersistGrant, bool shouldPresentIntroduction, bool shouldOfferToolsOnly) { IsUnlocked = isUnlocked; Reason = reason; ShouldPersistGrant = shouldPersistGrant; ShouldPresentIntroduction = shouldPresentIntroduction; ShouldOfferToolsOnly = shouldOfferToolsOnly; } } internal static class UnlockPolicy { public static UnlockDecision Decide(QuestState questState, UnlockReason persistedGrantReason, LegacyEvidence evidence, bool companionDataUnreadable, bool toolsOnlyPreference) { bool flag = QuestStateMachine.IsComplete(questState); bool shouldPresentIntroduction = !flag && !toolsOnlyPreference; bool shouldOfferToolsOnly = !flag; if (persistedGrantReason != UnlockReason.NotUnlocked) { return new UnlockDecision(isUnlocked: true, UnlockReason.PreviouslyGranted, shouldPersistGrant: false, shouldPresentIntroduction, shouldOfferToolsOnly); } if (flag) { return new UnlockDecision(isUnlocked: true, UnlockReason.QuestCompleted, shouldPersistGrant: false, shouldPresentIntroduction: false, shouldOfferToolsOnly: false); } if (toolsOnlyPreference) { return new UnlockDecision(isUnlocked: true, UnlockReason.ToolsOnlyPreference, shouldPersistGrant: true, shouldPresentIntroduction: false, shouldOfferToolsOnly); } switch (evidence) { case LegacyEvidence.Present: return new UnlockDecision(isUnlocked: true, UnlockReason.ExistingUserData, shouldPersistGrant: true, shouldPresentIntroduction, shouldOfferToolsOnly); case LegacyEvidence.Ambiguous: return new UnlockDecision(isUnlocked: true, UnlockReason.AmbiguousLegacyEvidence, shouldPersistGrant: true, shouldPresentIntroduction, shouldOfferToolsOnly); default: if (companionDataUnreadable) { return new UnlockDecision(isUnlocked: true, UnlockReason.DataUnreadable, shouldPersistGrant: true, shouldPresentIntroduction, shouldOfferToolsOnly); } return new UnlockDecision(isUnlocked: false, UnlockReason.NotUnlocked, shouldPersistGrant: false, shouldPresentIntroduction, shouldOfferToolsOnly); } } } internal enum UnlockReason { NotUnlocked, QuestCompleted, ExistingUserData, AmbiguousLegacyEvidence, DataUnreadable, ToolsOnlyPreference, PreviouslyGranted } } namespace TheConcernedCat.Companions.Surroundings { internal readonly struct CampFire { public WorldPoint Position { get; } public bool Burning { get; } public bool Sheltered { get; } public float HazardMetres { get; } public CampFire(WorldPoint position, bool burning, bool sheltered, float hazardMetres) { Position = position; Burning = burning; Sheltered = sheltered; HazardMetres = Math.Max(0f, hazardMetres); } } internal readonly struct CampBed { public WorldPoint Position { get; } public float YawDegrees { get; } public bool Claimed { get; } public bool Sheltered { get; } public CampBed(WorldPoint position, float yawDegrees, bool claimed, bool sheltered) { Position = position; YawDegrees = yawDegrees; Claimed = claimed; Sheltered = sheltered; } } internal sealed class CampSnapshot { public WorldPoint Home { get; } public bool Night { get; } public bool Wet { get; } public IReadOnlyList<CampFire> Fires { get; } public IReadOnlyList<CampBed> Beds { get; } public CampSnapshot(WorldPoint home, bool night, bool wet, IReadOnlyList<CampFire>? fires, IReadOnlyList<CampBed>? beds) { Home = home; Night = night; Wet = wet; Fires = fires ?? Array.Empty<CampFire>(); Beds = beds ?? Array.Empty<CampBed>(); } } internal sealed class CompanionTemperament { public static readonly CompanionTemperament Hulgi = new CompanionTemperament(lovesFire: true, sleepsInSpareBeds: true, 25f); public bool LovesFire { get; } public bool SleepsInSpareBeds { get; } public float CampRadiusMetres { get; } public CompanionTemperament(bool lovesFire, bool sleepsInSpareBeds, float campRadiusMetres) { LovesFire = lovesFire; SleepsInSpareBeds = sleepsInSpareBeds; CampRadiusMetres = ((campRadiusMetres > 0f && !float.IsNaN(campRadiusMetres)) ? campRadiusMetres : 25f); } } internal enum HangoutKind { Sleep, Fire, Shelter, Home } internal readonly struct HangoutIntent { public HangoutKind Kind { get; } public int Target { get; } public WorldPoint Focus { get; } public HangoutIntent(HangoutKind kind, int target, WorldPoint focus) { Kind = kind; Target = target; Focus = focus; } public override string ToString() { return Kind.ToString() + ((Target >= 0) ? (" #" + Target) : string.Empty) + " at " + Focus; } } internal static class CommonSense { public const int Nowhere = int.MaxValue; public static IReadOnlyList<HangoutIntent> Preferences(CampSnapshot camp, CompanionTemperament temperament) { if (camp == null) { throw new ArgumentNullException("camp"); } if (temperament == null) { throw new ArgumentNullException("temperament"); } List<HangoutIntent> list = new List<HangoutIntent>(); List<int> list2 = (temperament.LovesFire ? Nearest(camp, camp.Fires.Count, (int i) => camp.Fires[i].Position, (int i) => camp.Fires[i].Burning, temperament.CampRadiusMetres) : new List<int>()); if (camp.Night && temperament.SleepsInSpareBeds) { foreach (int item in Nearest(camp, camp.Beds.Count, (int i) => camp.Beds[i].Position, (int i) => !camp.Beds[i].Claimed, temperament.CampRadiusMetres)) { list.Add(new HangoutIntent(HangoutKind.Sleep, item, camp.Beds[item].Position)); } } if (camp.Night || camp.Wet) { foreach (int item2 in list2) { if (camp.Fires[item2].Sheltered) { list.Add(new HangoutIntent(HangoutKind.Fire, item2, camp.Fires[item2].Position)); } } list.Add(new HangoutIntent(HangoutKind.Shelter, -1, camp.Home)); foreach (int item3 in list2) { if (!camp.Fires[item3].Sheltered) { list.Add(new HangoutIntent(HangoutKind.Fire, item3, camp.Fires[item3].Position)); } } } else { foreach (int item4 in list2) { list.Add(new HangoutIntent(HangoutKind.Fire, item4, camp.Fires[item4].Position)); } } list.Add(new HangoutIntent(HangoutKind.Home, -1, camp.Home)); return list; } public static int Rank(int wishIndex, bool onSeat) { return Math.Max(0, wishIndex) * 2 + ((!onSeat) ? 1 : 0); } private static List<int> Nearest(CampSnapshot camp, int count, Func<int, WorldPoint> position, Func<int, bool> wanted, float radius) { List<int> list = new List<int>(); for (int i = 0; i < count; i++) { if (wanted(i) && position(i).HorizontalDistanceTo(camp.Home) <= radius) { list.Add(i); } } list.Sort(delegate(int left, int right) { WorldPoint worldPoint = position(left); WorldPoint worldPoint2 = position(right); int num = worldPoint.HorizontalDistanceTo(camp.Home).CompareTo(worldPoint2.HorizontalDistanceTo(camp.Home)); if (num != 0) { return num; } int num2 = worldPoint.X.CompareTo(worldPoint2.X); return (num2 == 0) ? worldPoint.Z.CompareTo(worldPoint2.Z) : num2; }); return list; } } internal enum DoorAccessPolicy { OnlyAllowedDoors, AllDoors } internal readonly struct DoorPlace : IEquatable<DoorPlace> { public const float MatchMetres = 0.35f; public const float MatchHeightMetres = 0.5f; public WorldPoint Position { get; } public int Prefab { get; } public DoorPlace(WorldPoint position, int prefab) { Position = position; Prefab = prefab; } public bool SamePlaceAs(DoorPlace other) { if (Prefab != 0 && other.Prefab != 0 && Prefab != other.Prefab) { return false; } if (Position.HorizontalDistanceTo(other.Position) <= 0.35f) { return Math.Abs(Position.Y - other.Position.Y) <= 0.5f; } return false; } public bool Equals(DoorPlace other) { if (Prefab == other.Prefab) { return Position.Equals(other.Position); } return false; } public override bool Equals(object? obj) { if (obj is DoorPlace other) { return Equals(other); } return false; } public override int GetHashCode() { return (Prefab * 397) ^ Position.GetHashCode(); } public override string ToString() { return string.Format(CultureInfo.InvariantCulture, "{0:0.0},{1:0.0},{2:0.0}", Position.X, Position.Y, Position.Z); } } internal sealed class DoorAccessBook { private readonly List<DoorPlace> _allowed = new List<DoorPlace>(); public IReadOnlyList<DoorPlace> Allowed => _allowed; public bool IsDirty { get; private set; } public bool IsAllowed(DoorPlace door, DoorAccessPolicy policy) { if (policy != DoorAccessPolicy.AllDoors) { return IndexOf(door) >= 0; } return true; } public bool SetAllowed(DoorPlace door, bool allowed) { int num = IndexOf(door); if (allowed == num >= 0) { return false; } if (allowed) { _allowed.Add(door); } else { _allowed.RemoveAt(num); } IsDirty = true; return true; } public bool Toggle(DoorPlace door) { bool flag = IndexOf(door) < 0; SetAllowed(door, flag); return flag; } public int Clear() { int count = _allowed.Count; if (count > 0) { _allowed.Clear(); IsDirty = true; } return count; } public void MarkClean() { IsDirty = false; } internal void Restore(DoorPlace door) { if (IndexOf(door) < 0) { _allowed.Add(door); } } private int IndexOf(DoorPlace door) { for (int i = 0; i < _allowed.Count; i++) { if (_allowed[i].SamePlaceAs(door)) { return i; } } return -1; } } internal static class DoorAccessCodec { public const string Header = "# Concerned Companions: doors companions may use in this world"; private const string DoorRow = "door"; public static IEnumerable<string> Serialize(DoorAccessBook book) { yield return "# Concerned Companions: doors companions may use in this world"; foreach (DoorPlace item in book.Allowed) { yield return string.Join("\t", "door", item.Prefab.ToString(CultureInfo.InvariantCulture), item.Position.X.ToString("0.###", CultureInfo.InvariantCulture), item.Position.Y.ToString("0.###", CultureInfo.InvariantCulture), item.Position.Z.ToString("0.###", CultureInfo.InvariantCulture)); } } public static DoorAccessBook Parse(IEnumerable<string>? lines, out int skipped) { DoorAccessBook doorAccessBook = new DoorAccessBook(); skipped = 0; if (lines == null) { return doorAccessBook; } foreach (string line in lines) { string text = line?.Trim() ?? string.Empty; if (text.Length == 0 || text.StartsWith("#", StringComparison.Ordinal)) { continue; } string[] array = text.Split(new char[1] { '\t' }); if (!(array[0] != "door")) { if (array.Length < 5 || !int.TryParse(array[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) || !TryReadFloat(array[2], out var value) || !TryReadFloat(array[3], out var value2) || !TryReadFloat(array[4], out var value3)) { skipped++; } else { doorAccessBook.Restore(new DoorPlace(new WorldPoint(value, value2, value3), result)); } } } doorAccessBook.MarkClean(); return doorAccessBook; } private static bool TryReadFloat(string text, out float value) { if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && !float.IsNaN(value)) { return !float.IsInfinity(value); } return false; } } internal readonly struct DoorPortal { public const float HeightBand = 1.2f; public DoorPlace Place { get; } public WorldPoint FloorCentre { get; } public float NormalX { get; } public float NormalZ { get; } public float HalfWidth { get; } public bool IsOpen { get; } public bool CanOpen { get; } public bool Allowed { get; } public bool IsPassable { get { if (Allowed) { if (!IsOpen) { return CanOpen; } return true; } return false; } } public DoorPortal(DoorPlace place, WorldPoint floorCentre, float normalX, float normalZ, float halfWidth, bool isOpen, bool canOpen, bool allowed) { float num = (float)Math.Sqrt(normalX * normalX + normalZ * normalZ); if (num < 0.0001f || float.IsNaN(num)) { normalX = 0f; normalZ = 1f; num = 1f; } Place = place; FloorCentre = floorCentre; NormalX = normalX / num; NormalZ = normalZ / num; HalfWidth = Math.Max(0.1f, halfWidth); IsOpen = isOpen; CanOpen = canOpen; Allowed = allowed; } public int SideOf(WorldPoint point) { if (!(SignedDistance(point) >= 0f)) { return -1; } return 1; } public WorldPoint OutFrom(int side, float metres) { float num = ((side >= 0) ? metres : (0f - metres)); return new WorldPoint(FloorCentre.X + NormalX * num, FloorCentre.Y, FloorCentre.Z + NormalZ * num); } public bool IsCrossedBy(WorldPoint from, WorldPoint to) { float num = SignedDistance(from); float num2 = SignedDistance(to); if ((num > 0f && num2 > 0f) || (num < 0f && num2 < 0f) || num == num2) { return false; } float num3 = num / (num - num2); float num4 = from.X + (to.X - from.X) * num3; float num5 = from.Y + (to.Y - from.Y) * num3; float num6 = from.Z + (to.Z - from.Z) * num3; if (Math.Abs((num4 - FloorCentre.X) * (0f - NormalZ) + (num6 - FloorCentre.Z) * NormalX) <= HalfWidth) { return Math.Abs(num5 - FloorCentre.Y) <= 1.2f; } return false; } private float SignedDistance(WorldPoint point) { return (point.X - FloorCentre.X) * NormalX + (point.Z - FloorCentre.Z) * NormalZ; } } internal enum RouteDoorVerdict { Clear, NeedsOpening, Forbidden } internal readonly struct RouteDoorCheck { public static RouteDoorCheck Clear => new RouteDoorCheck(RouteDoorVerdict.Clear, -1, -1); public RouteDoorVerdict Verdict { get; } public int Door { get; } public int Leg { get; } public RouteDoorCheck(RouteDoorVerdict verdict, int doorIndex, int legIndex) { Verdict = verdict; Door = doorIndex; Leg = legIndex; } } internal static class RouteDoors { public static RouteDoorCheck Inspect(IReadOnlyList<WorldPoint> route, IReadOnlyList<DoorPortal> doors) { if (route == null || doors == null || route.Count < 2 || doors.Count == 0) { return RouteDoorCheck.Clear; } RouteDoorCheck result = RouteDoorCheck.Clear; for (int i = 0; i + 1 < route.Count; i++) { for (int j = 0; j < doors.Count; j++) { DoorPortal doorPortal = doors[j]; if (doorPortal.IsCrossedBy(route[i], route[i + 1])) { if (!doorPortal.IsPassable) { return new RouteDoorCheck(RouteDoorVerdict.Forbidden, j, i); } if (!doorPortal.IsOpen && result.Verdict == RouteDoorVerdict.Clear) { result = new RouteDoorCheck(RouteDoorVerdict.NeedsOpening, j, i); } } } } return result; } } internal readonly struct WalkSetback { public WorldPoint Spot { get; } public bool HasPlace { get; } public WorldPoint StoppedAt { get; } public float HeadingX { get; } public float HeadingZ { get; } public float Until { get; } public float PauseSeconds { get; } public int Strikes { get; } public WalkSetback(WorldPoint spot, bool hasPlace, WorldPoint stoppedAt, float headingX, float headingZ, float until, float pauseSeconds, int strikes) { Spot = spot; HasPlace = hasPlace; StoppedAt = stoppedAt; HeadingX = headingX; HeadingZ = headingZ; Until = until; PauseSeconds = pauseSeconds; Strikes = strikes; } public bool IsActiveAt(float now) { return now < Until; } } internal sealed class WalkSetbacks { public const float FirstPauseSeconds = 60f; public const float LongestPauseSeconds = 300f; public const float RecallSeconds = 600f; public const int Capacity = 8; public const float SameSpotMetres = 1.5f; public const float SamePlaceMetres = 1f; public const float SameLevelMetres = 1.5f; public const float SameWayCosine = 0.5f; private const float ShortestHeadingMetres = 0.05f; private const float PastPlaceMetres = 0.5f; private readonly List<WalkSetback> _setbacks = new List<WalkSetback>(); public IReadOnlyList<WalkSetback> Remembered => _setbacks; public WalkSetback Remember(WorldPoint spot, float now) { return Add(spot, hasPlace: false, default(WorldPoint), 0f, 0f, now); } public WalkSetback Remember(WorldPoint spot, WorldPoint stoppedAt, WorldPoint headingTowards, float now) { if (!TryHeading(stoppedAt, headingTowards, out var headingX, out var headingZ) && !TryHeading(stoppedAt, spot, out headingX, out headingZ)) { return Add(spot, hasPlace: false, default(WorldPoint), 0f, 0f, now); } return Add(spot, hasPlace: true, stoppedAt, headingX, headingZ, now); } public bool RefusesSpot(WorldPoint spot, float now) { foreach (WalkSetback setback in _setbacks) { if (setback.IsActiveAt(now) && setback.Spot.HorizontalDistanceTo(spot) <= 1.5f && setback.Spot.VerticalDistanceTo(spot) <= 1.5f) { return true; } } return false; } public bool RefusesRoute(IReadOnlyList<WorldPoint> route, float now) { if (route == null || route.Count < 2) { return false; } foreach (WalkSetback setback in _setbacks) { if (!setback.HasPlace || !setback.IsActiveAt(now)) { continue; } for (int i = 0; i + 1 < route.Count; i++) { if (PassesTheSameWay(route[i], route[i + 1], setback)) { return true; } } } return false; } public void Clear() { _setbacks.Clear(); } private WalkSetback Add(WorldPoint spot, bool hasPlace, WorldPoint stoppedAt, float headingX, float headingZ, float now) { _setbacks.RemoveAll((WalkSetback setback) => now >= setback.Until + 600f); int num = 0; for (int num2 = _setbacks.Count - 1; num2 >= 0; num2--) { WalkSetback walkSetback = _setbacks[num2]; bool num3 = walkSetback.Spot.HorizontalDistanceTo(spot) <= 1.5f && walkSetback.Spot.VerticalDistanceTo(spot) <= 1.5f; bool flag = hasPlace && walkSetback.HasPlace && walkSetback.StoppedAt.HorizontalDistanceTo(stoppedAt) <= 1f && walkSetback.StoppedAt.VerticalDistanceTo(stoppedAt) <= 1.5f && walkSetback.HeadingX * headingX + walkSetback.HeadingZ * headingZ >= 0.5f; if (num3 || flag) { num = Math.Max(num, walkSetback.Strikes); _setbacks.RemoveAt(num2); } } num++; float num4 = PauseFor(num); WalkSetback walkSetback2 = new WalkSetback(spot, hasPlace, stoppedAt, headingX, headingZ, now + num4, num4, num); while (_setbacks.Count >= 8) { int index = 0; for (int num5 = 1; num5 < _setbacks.Count; num5++) { if (_setbacks[num5].Until < _setbacks[index].Until) { index = num5; } } _setbacks.RemoveAt(index); } _setbacks.Add(walkSetback2); return walkSetback2; } public static float PauseFor(int strikes) { float num = 60f; for (int i = 1; i < strikes; i++) { if (!(num < 300f)) { break; } num *= 2f; } return Math.Min(num, 300f); } private static bool TryHeading(WorldPoint from, WorldPoint to, out float headingX, out float headingZ) { float num = to.X - from.X; float num2 = to.Z - from.Z; float num3 = (float)Math.Sqrt(num * num + num2 * num2); if (num3 < 0.05f || float.IsNaN(num3)) { headingX = 0f; headingZ = 0f; return false; } headingX = num / num3; headingZ = num2 / num3; return true; } private static bool PassesTheSameWay(WorldPoint from, WorldPoint to, WalkSetback setback) { if (!TryHeading(from, to, out var headingX, out var headingZ) || headingX * setback.HeadingX + headingZ * setback.HeadingZ < 0.5f) { return false; } float num = from.HorizontalDistanceTo(to); float num2 = setback.StoppedAt.X - from.X; float num3 = setback.StoppedAt.Z - from.Z; float num4 = num2 * headingX + num3 * headingZ; if (num4 > num + 0.05f || num4 < -0.5f) { return false; } float num5 = Math.Max(0f, num4); float num6 = from.X + headingX * num5; float num7 = from.Z + headingZ * num5; float num8 = setback.StoppedAt.X - num6; float num9 = setback.StoppedAt.Z - num7; if (num8 * num8 + num9 * num9 > 1f) { return false; } return Math.Abs(from.Y + (to.Y - from.Y) * (num5 / num) - setback.StoppedAt.Y) <= 1.5f; } } } namespace TheConcernedCat.Companions.Session { internal sealed class CompanionProgress { private readonly CompanionSidecarStore _store; private readonly CompanionSidecar _sidecar; private readonly QuestId _questId; private readonly LegacyEvidence _evidence; private bool _toolsOnlyPreference; private bool _noticeCameFromSave; public CompanionScope Scope => _sidecar.Scope; public SidecarLoadOutcome LoadOutcome { get; } public string? Notice { get; private set; } public UnlockDecision Decision { get; private set; } public bool IsUnlocked => Decision.IsUnlocked; public QuestState QuestState { get { if (!_sidecar.TryGetQuest(_questId, out CompanionQuestRecord record)) { return QuestState.Unstarted; } return record.State; } } public bool HasCompanion => QuestStateMachine.HasCompanion(QuestState); public bool ShouldPresentCollectible { get { if (QuestStateMachine.ShouldPresentCollectible(QuestState)) { return Decision.ShouldPresentIntroduction; } return false; } } public bool PresentationRetired { get { if (_sidecar.TryGetQuest(_questId, out CompanionQuestRecord record)) { return record.PresentationRetired; } return false; } } public bool CanReplayStory => QuestStateMachine.CanReplayStory(QuestState); public bool ShouldAnnounceJoin { get { if (HasCompanion) { if (_sidecar.TryGetQuest(_questId, out CompanionQuestRecord record)) { return !record.JoinAnnounced; } return true; } return false; } } public bool HasUnsavedChanges => _sidecar.IsDirty; private CompanionProgress(CompanionSidecarStore store, CompanionSidecar sidecar, QuestId questId, SidecarLoadOutcome loadOutcome, string? notice, LegacyEvidence evidence, bool toolsOnlyPreference) { _store = store; _sidecar = sidecar; _questId = questId; _evidence = evidence; _toolsOnlyPreference = toolsOnlyPreference; LoadOutcome = loadOutcome; Notice = notice; } public static CompanionProgress Open(CompanionSidecarStore store, CompanionScope scope, QuestId questId, LegacyEvidence evidence = LegacyEvidence.None, bool toolsOnlyPreference = false) { if (store == null) { throw new ArgumentNullException("store"); } CompanionSidecarStore.LoadReport loadReport = store.Load(scope); CompanionProgress companionProgress = new CompanionProgress(store, loadReport.Sidecar, questId, loadReport.Outcome, loadReport.Notice, evidence, toolsOnlyPreference); companionProgress.Resolve(); return companionProgress; } public bool AnnounceJoin() { if (!ShouldAnnounceJoin || !_sidecar.MarkJoinAnnounced(_questId)) { return false; } Save(); return !_sidecar.IsDirty; } public QuestTransitionOutcome Advance(QuestTransition transition) { QuestTransitionOutcome num = _sidecar.Apply(_questId, transition); if (num == QuestTransitionOutcome.Advanced) { Save(); Resolve(); } return num; } public bool ResetQuest() { if (_sidecar.IsReadOnly || !_sidecar.TryGetQuest(_questId, out CompanionQuestRecord _)) { return false; } if (Decision.IsUnlocked && _sidecar.GrantedReason == UnlockReason.NotUnlocked) { _sidecar.RecordUnlockGrant(Decision.Reason); } if (!_sidecar.ResetQuest(_questId)) { return false; } Save(); Resolve(); return true; } public bool TryRetirePresentation() { if (!QuestStateMachine.IsComplete(QuestState)) { return false; } if (_sidecar.IsDirty) { return false; } if (PresentationRetired) { return false; } _sidecar.MarkPresentationRetired(_questId); return Save(); } public void SetToolsOnlyPreference(bool enabled) { _toolsOnlyPreference = enabled; Resolve(); } private void Resolve() { bool companionDataUnreadable = _sidecar.IsReadOnly || _sidecar.HasForwardData || SidecarLoadOutcomes.IndicatesPriorData(LoadOutcome); UnlockDecision decision = UnlockPolicy.Decide(_sidecar.HasAnyCompletedQuest() ? QuestState.Recruited : QuestState, _sidecar.GrantedReason, _evidence, companionDataUnreadable, _toolsOnlyPreference); decision = new UnlockDecision(decision.IsUnlocked, decision.Reason, decision.ShouldPersistGrant, !QuestStateMachine.IsComplete(QuestState) && !_toolsOnlyPreference, !QuestStateMachine.IsComplete(QuestState)); if (decision.ShouldPersistGrant && _sidecar.RecordUnlockGrant(decision.Reason)) { Save(); } if (_sidecar.NeedsQuarantineRewrite && Save(force: true)) { _sidecar.QuarantineRewritten(); } Decision = decision; } private bool Save(bool force = false) { CompanionSidecarStore.SaveReport saveReport = _store.Save(_sidecar, force); if (!saveReport.Saved) { if (saveReport.Notice != null) { Notice = saveReport.Notice; _noticeCameFromSave = true; } return false; } if (_noticeCameFromSave) { _noticeCameFromSave = false; Notice = null; } return true; } } } namespace TheConcernedCat.Companions.Quest { internal enum QuestState { Unstarted, Discovered, Collected, IntroPending, Recruited, Skipped } internal static class QuestStateMachine { public static bool IsComplete(QuestState state) { if (state != QuestState.Recruited) { return state == QuestState.Skipped; } return true; } public static bool HasCompanion(QuestState state) { return state == QuestState.Recruited; } public static bool CanReplayStory(QuestState state) { return IsComplete(state); } public static bool ShouldPresentCollectible(QuestState state) { return !IsComplete(state); } public static QuestTransitionOutcome TryApply(QuestState current, QuestTransition transition, out QuestState next) { next = current; switch (transition) { case QuestTransition.Discover: return Promote(current, QuestState.Discovered, ref next); case QuestTransition.Collect: return Promote(current, QuestState.Collected, ref next); case QuestTransition.BeginIntroduction: return Promote(current, QuestState.IntroPending, ref next); case QuestTransition.Welcome: if (current == QuestState.Recruited) { return QuestTransitionOutcome.AlreadySatisfied; } next = QuestState.Recruited; return QuestTransitionOutcome.Advanced; case QuestTransition.Skip: if (IsComplete(current)) { return QuestTransitionOutcome.AlreadySatisfied; } next = QuestState.Skipped; return QuestTransitionOutcome.Advanced; default: return QuestTransitionOutcome.Rejected; } } private static QuestTransitionOutcome Promote(QuestState current, QuestState target, ref QuestState next) { if (IsComplete(current) || Rank(current) >= Rank(target)) { return QuestTransitionOutcome.AlreadySatisfied; } next = target; return QuestTransitionOutcome.Advanced; } public static int Rank(QuestState state) { return state switch { QuestState.Unstarted => 0, QuestState.Discovered => 1, QuestState.Collected => 2, QuestState.IntroPending => 3, QuestState.Recruited => 4, QuestState.Skipped => 4, _ => 0, }; } public static bool IsKnown(QuestState state) { if ((uint)state <= 5u) { return true; } return false; } } internal enum QuestTransition { Discover, Collect, BeginIntroduction, Welcome, Skip } internal enum QuestTransitionOutcome { Advanced, AlreadySatisfied, Rejected } } namespace TheConcernedCat.Companions.Placement { internal enum AnchorKind { None, ClaimedBed, DefaultSpawn } internal readonly struct CompanionAnchor : IEquatable<CompanionAnchor> { public static readonly CompanionAnchor None; public AnchorKind Kind { get; } public WorldPoint Position { get; } public bool IsValid => Kind != AnchorKind.None; public CompanionAnchor(AnchorKind kind, WorldPoint position) { Kind = kind; Position = position; } public bool DiffersFrom(CompanionAnchor other, float tolerance) { if (Kind != other.Kind) { return true; } if (!IsValid) { return false; } if (!(Position.HorizontalDistanceTo(other.Position) > tolerance)) { return Position.VerticalDistanceTo(other.Position) > tolerance; } return true; } public bool Equals(CompanionAnchor other) { if (Kind == other.Kind) { return Position.Equals(other.Position); } return false; } public override bool Equals(object? obj) { if (obj is CompanionAnchor other) { return Equals(other); } return false; } public override int GetHashCode() { return ((int)Kind * 397) ^ Position.GetHashCode(); } public override string ToString() { if (!IsValid) { return "no anchor"; } return Kind.ToString() + " " + Position; } } internal enum CompanionPose { SitOnGround, SitByFire, SitOnSeat, SleepInBed } internal interface IAnchorSource { bool TryGetAnchor(out CompanionAnchor anchor); } internal interface IPlacementProbe { PlacementProbeSample Probe(WorldPoint position); } internal interface ISeatFinder { IReadOnlyList<PlacementProbeSample> FindSeats(WorldPoint center, float radius); } internal sealed class PlacementPlanner { private readonly struct Qualified { public PlacementProbeSample Sample { get; } public CompanionPose Pose { get; } public SeatOffer Seat { get; } public int Score { get; } public float RadiusError { get; } public int Order { get; } public Qualified(PlacementProbeSample sample, CompanionPose pose, SeatOffer seat, int score, float radiusError, int order) { Sample = sample; Pose = pose; Seat = seat; Score = score; RadiusError = radiusError; Order = order; } } private const int RingCount = 4; private const int AnglesPerRing = 12; public const int MaximumCandidates = 48; public const int MaximumSeats = 8; private const float RingBearingOffsetDegrees = 11f; private readonly PlacementRules _rules; public PlacementPlanner(PlacementRules? rules = null) { _rules = rules ?? PlacementRules.Default; } public PlacementResult Plan(CompanionAnchor anchor, IPlacementProbe probe, Action<PlacementProbeSample, PlacementRejection, bool>? observe = null, Func<PlacementProbeSample, bool>? accept = null) { if (probe == null) { throw new ArgumentNullException("probe"); } if (!anchor.IsValid) { return PlacementResult.Deferred(0, PlacementRejection.NotLoaded); } float num = (_rules.MinimumRadius + _rules.MaximumRadius) * 0.5f; List<Qualified> list = new List<Qualified>(); PlacementRejection placementRejection = PlacementRejection.None; bool flag = false; int num2 = 0; for (int i = 0; i < 4; i++) { float num3 = RadiusForRing(i); float num4 = (float)i * 11f; for (int j = 0; j < 12; j++) { double num5 = (double)(num4 + (float)j * 30f) * Math.PI / 180.0; WorldPoint position = new WorldPoint(anchor.Position.X + (float)(Math.Cos(num5) * (double)num3), anchor.Position.Y, anchor.Position.Z + (float)(Math.Sin(num5) * (double)num3)); PlacementProbeSample placementProbeSample = probe.Probe(position); num2++; if ((placementProbeSample.Rejections & PlacementRejection.NotLoaded) != PlacementRejection.None) { flag = true; } PlacementRejection placementRejection2 = placementProbeSample.Rejections | RangeRejections(anchor, placementProbeSample); observe?.Invoke(placementProbeSample, placementRejection2, arg3: false); if (placementRejection2 != PlacementRejection.None) { placementRejection |= placementRejection2; continue; } CompanionPose companionPose = PoseFor(placementProbeSample); list.Add(new Qualified(placementProbeSample, companionPose, (companionPose == CompanionPose.SitOnSeat) ? placementProbeSample.SeatOffer : SeatOffer.None, Score(placementProbeSample), Math.Abs(placementProbeSample.Position.HorizontalDistanceTo(anchor.Position) - num), list.Count)); } } if (probe is ISeatFinder seatFinder) { IReadOnlyList<PlacementProbeSample> obj = seatFinder.FindSeats(anchor.Position, _rules.MaximumRadius) ?? Array.Empty<PlacementProbeSample>(); int num6 = 0; foreach (PlacementProbeSample item in obj) { if (num6 >= 8) { break; } if (item.SeatOffer.IsUsable) { num6++; PlacementRejection placementRejection3 = item.Rejections | SeatRangeRejections(anchor, item); observe?.Invoke(item, placementRejection3, arg3: true); if (placementRejection3 != PlacementRejection.None) { placementRejection |= placementRejection3; } else { list.Add(new Qualified(item, CompanionPose.SitOnSeat, item.SeatOffer, Score(item), Math.Abs(item.Position.HorizontalDistanceTo(anchor.Position) - num), list.Count)); } } } } if (flag) { return PlacementResult.Deferred(num2, placementRejection | PlacementRejection.NotLoaded); } list.Sort(delegate(Qualified left, Qualified right) { int num7 = right.Score.CompareTo(left.Score); if (num7 != 0) { return num7; } int num8 = left.RadiusError.CompareTo(right.RadiusError); return (num8 == 0) ? left.Order.CompareTo(right.Order) : num8; }); foreach (Qualified item2 in list) { if (accept == null || accept(item2.Sample)) { return PlacementResult.Placed(item2.Sample.Position, item2.Pose, num2, item2.Seat, item2.Score); } } return PlacementResult.Deferred(num2, placementRejection); } public bool ShouldReposition(CompanionAnchor previous, CompanionAnchor current) { return current.DiffersFrom(previous, _rules.AnchorMoveTolerance); } private float RadiusForRing(int ring) { float num = _rules.MaximumRadius - _rules.MinimumRadius; return _rules.MinimumRadius + num * (float)ring / 3f; } private PlacementRejection RangeRejections(CompanionAnchor anchor, PlacementProbeSample sample) { float num = sample.Position.HorizontalDistanceTo(anchor.Position); if (num < _rules.MinimumRadius - 0.01f || num > _rules.MaximumRadius + 0.01f) { return PlacementRejection.OutOfRange; } if (!(sample.Position.VerticalDistanceTo(anchor.Position) > _rules.MaximumHeightDelta + 0.01f)) { return PlacementRejection.None; } return PlacementRejection.OutOfRange; } private PlacementRejection SeatRangeRejections(CompanionAnchor anchor, PlacementProbeSample seat) { if (!(seat.Position.HorizontalDistanceTo(anchor.Position) > _rules.MaximumRadius + 0.01f) && !(seat.Position.VerticalDistanceTo(anchor.Position) > _rules.MaximumHeightDelta + 0.01f)) { return PlacementRejection.None; } return PlacementRejection.OutOfRange; } public int ValueOf(PlacementProbeSample sample) { return Score(sample); } private int Score(PlacementProbeSample sample) { int num = 0; if (IsWarm(sample)) { num += 2; } if (sample.SeatOffer.IsUsable) { num++; } return num; } private CompanionPose PoseFor(PlacementProbeSample sample) { if (sample.SeatOffer.IsUsable) { return CompanionPose.SitOnSeat; } if (!IsWarm(sample)) { return CompanionPose.SitOnGround; } return CompanionPose.SitByFire; } private bool IsWarm(PlacementProbeSample sample) { if (sample.HasFireNearby) { return sample.DistanceToFire <= _rules.FireComfortRadius; } return false; } } internal readonly struct PlacementProbeSample { public WorldPoint Position { get; } public PlacementRejection Rejections { get; } public float DistanceToFire { get; } public SeatOffer SeatOffer { get; } public SeatAvailability Seat => SeatOffer.Availability; public bool IsUsable => Rejections == PlacementRejection.None; public bool HasFireNearby => DistanceToFire >= 0f; public PlacementProbeSample(WorldPoint position, PlacementRejection rejections, float distanceToFire, SeatAvailability seat) : this(position, rejections, distanceToFire, Offer(seat)) { } public PlacementProbeSample(WorldPoint position, PlacementRejection rejections, float distanceToFire, SeatOffer seat) { Position = position; Rejections = rejections; DistanceToFire = distanceToFire; SeatOffer = seat; } public static PlacementProbeSample NotLoaded(WorldPoint position) { return new PlacementProbeSample(position, PlacementRejection.NotLoaded, -1f, SeatOffer.None); } private static SeatOffer Offer(SeatAvailability seat) { switch (seat) { case SeatAvailability.Occupied: return SeatOffer.Occupied; case SeatAvailability.Free: case SeatAvailability.Unverified: return SeatOffer.Unverified; default: return SeatOffer.None; } } } [Flags] internal enum PlacementRejection { None = 0, NotLoaded = 1, Water = 2, Unsupported = 4, TooSteep = 8, Occupied = 0x10, Fire = 0x20, Doorway = 0x40, Bed = 0x80, Shrine = 0x100, OutOfRange = 0x200 } internal readonly struct PlacementResult { public int Value { get; } public bool Found { get; } public WorldPoint Position { get; } public CompanionPose Pose { get; } public int CandidatesProbed { get; } public PlacementRejection BlockedBy { get; } public SeatOffer Seat { get; } private PlacementResult(bool found, WorldPoint position, CompanionPose pose, int candidatesProbed, PlacementRejection blockedBy, SeatOffer seat, int value) { Found = found; Position = position; Pose = pose; CandidatesProbed = candidatesProbed; BlockedBy = blockedBy; Seat = seat; Value = value; } public static PlacementResult Placed(WorldPoint position, CompanionPose pose, int candidatesProbed, SeatOffer seat = default(SeatOffer), int value = 0) { return new PlacementResult(found: true, position, pose, candidatesProbed, PlacementRejection.None, seat, value); } public static PlacementResult Deferred(int candidatesProbed, PlacementRejection blockedBy) { return new PlacementResult(found: false, default(WorldPoint), CompanionPose.SitOnGround, candidatesProbed, blockedBy, SeatOffer.None, 0); } } internal sealed class PlacementRules { public const float DefaultMinimumRadius = 3f; public const float DefaultMaximumRadius = 10f; public const float DefaultFireComfortRadius = 5f; public const float DefaultMaximumHeightDelta = 3f; public const float DefaultAnchorMoveTolerance = 1.5f; public float MinimumRadius { get; } public float MaximumRadius { get; } public float FireComfortRadius { get; } public float MaximumHeightDelta { get; } public float AnchorMoveTolerance { get; } public static PlacementRules Default { get; } = new PlacementRules(); public PlacementRules(float minimumRadius = 3f, float maximumRadius = 10f, float fireComfortRadius = 5f, float maximumHeightDelta = 3f, float anchorMoveTolerance = 1.5f) { RequireFinite(minimumRadius, "minimumRadius"); RequireFinite(maximumRadius, "maximumRadius"); RequireFinite(fireComfortRadius, "fireComfortRadius"); RequireFinite(maximumHeightDelta, "maximumHeightDelta"); RequireFinite(anchorMoveTolerance, "anchorMoveTolerance"); RequireNotNegative(minimumRadius, "minimumRadius"); RequireNotNegative(fireComfortRadius, "fireComfortRadius"); RequireNotNegative(maximumHeightDelta, "maximumHeightDelta"); RequireNotNegative(anchorMoveTolerance, "anchorMoveTolerance"); if (maximumRadius < minimumRadius) { throw new ArgumentOutOfRangeException("maximumRadius", "The maximum placement radius cannot be below the minimum."); } MinimumRadius = minimumRadius; MaximumRadius = maximumRadius; FireComfortRadius = fireComfortRadius; MaximumHeightDelta = maximumHeightDelta; AnchorMoveTolerance = anchorMoveTolerance; } private static void RequireFinite(float value, string parameterName) { if (float.IsNaN(value) || float.IsInfinity(value)) { throw new ArgumentOutOfRangeException(parameterName, "Placement bounds must be finite numbers."); } } private static void RequireNotNegative(float value, string parameterName) { if (value < 0f) { throw new ArgumentOutOfRangeException(parameterName, "Placement bounds cannot be negative."); } } } internal enum SeatAvailability { None, Free, Occupied, Unverified } internal readonly struct SeatOffer { public SeatAvailability Availability { get; } public WorldPoint Position { get; } public float YawDegrees { get; } public string? AttachAnimation { get; } public bool IsUsable => Availability == SeatAvailability.Free; public static SeatOffer None => new SeatOffer(SeatAvailability.None, default(WorldPoint), 0f, null); public static SeatOffer Occupied => new SeatOffer(SeatAvailability.Occupied, default(WorldPoint), 0f, null); public static SeatOffer Unverified => new SeatOffer(SeatAvailability.Unverified, default(WorldPoint), 0f, null); private SeatOffer(SeatAvailability availability, WorldPoint position, float yawDegrees, string? attachAnimation) { Availability = availability; Position = position; YawDegrees = yawDegrees; AttachAnimation = attachAnimation; } public static SeatOffer Free(WorldPoint position, float yawDegrees, string? attachAnimation) { return new SeatOffer(SeatAvailability.Free, position, yawDegrees, attachAnimation); } public override string ToString() { if (!IsUsable) { return Availability.ToString(); } return string.Format("{0} at {1} facing {2:0}° ({3})", Availability, Position, YawDegrees, AttachAnimation ?? "default pose"); } } internal readonly struct WorldPoint : IEquatable<WorldPoint> { public float X { get; } public float Y { get; } public float Z { get; } public WorldPoint(float x, float y, float z) { X = x; Y = y; Z = z; } public float HorizontalDistanceTo(WorldPoint other) { float num = X - other.X; float num2 = Z - other.Z; return (float)Math.Sqrt(num * num + num2 * num2); } public float VerticalDistanceTo(WorldPoint other) { return Math.Abs(Y - other.Y); } public WorldPoint WithHeight(float y) { return new WorldPoint(X, y, Z); } public bool Equals(WorldPoint other) { if (X.Equals(other.X) && Y.Equals(other.Y)) { return Z.Equals(other.Z); } return false; } public override bool Equals(object? obj) { if (obj is WorldPoint other) { return Equals(other); } return false; } public override int GetHashCode() { return (((X.GetHashCode() * 397) ^ Y.GetHashCode()) * 397) ^ Z.GetHashCode(); } public override string ToString() { return string.Format(CultureInfo.InvariantCulture, "({0:0.##}, {1:0.##}, {2:0.##})", X, Y, Z); } } } namespace TheConcernedCat.Companions.Persistence { internal sealed class CompanionQuestRecord { private readonly List<string> _unknownFields = new List<string>(); public QuestId QuestId { get; } public QuestState State { get; private set; } public int Revision { get; private set; } public bool PresentationRetired { get; private set; } public bool JoinAnnounced { get; private set; } public IReadOnlyList<string> UnknownFields => _unknownFields; public bool IsComplete => QuestStateMachine.IsComplete(State); public CompanionQuestRecord(QuestId questId, QuestState state) { QuestId = questId; State = state; } public QuestTransitionOutcome Apply(QuestTransition transition) { QuestState next; QuestTransitionOutcome num = QuestStateMachine.TryApply(State, transition, out next); if (num == QuestTransitionOutcome.Advanced) { State = next; Revision++; } return num; } public bool MarkPresentationRetired() { if (PresentationRetired) { return false; } PresentationRetired = true; Revision++; return true; } public bool MarkJoinAnnounced() { if (JoinAnnounced) { return false; } JoinAnnounced = true; Revision++; return true; } internal static CompanionQuestRecord Restore(QuestId questId, QuestState state, int revision, bool presentationRetired, IEnumerable<string>? unknownFields, bool joinAnnounced = false) { CompanionQuestRecord companionQuestRecord = new CompanionQuestRecord(questId, state) { Revision = ((revision >= 0) ? revision : 0), PresentationRetired = presentationRetired, JoinAnnounced = joinAnnounced }; if (unknownFields != null) { foreach (string unknownField in unknownFields) { companionQuestRecord._unknownFields.Add(unknownField ?? string.Empty); } } return companionQuestRecord; } public override string ToString() { return QuestId.Value + "=" + State.ToString() + " r" + Revision.ToString(CultureInfo.InvariantCulture); } } internal sealed class CompanionSidecar { private readonly Dictionary<string, CompanionQuestRecord> _quests = new Dictionary<string, CompanionQuestRecord>(StringComparer.Ordinal); private readonly List<string> _order = new List<string>(); private readonly List<string> _forwardLines = new List<string>(); private readonly List<string> _quarantinedLines = new List<string>(); public CompanionScope Scope { get; } public bool IsDirty { get; private set; } public bool IsReadOnly { get; private set; } public bool HasForwardData { get; private set; } public IReadOnlyList<string> ForwardLines => _forwardLines; public IReadOnlyList<string> QuarantinedLines => _quarantinedLines; public bool HasPreviouslyCarriedDamage { get; private set; } public IReadOnlyList<CompanionQuestRecord> Quests { get { List<CompanionQuestRecord> list = new List<CompanionQuestRecord>(_order.Count); foreach (string item in _order) { list.Add(_quests[item]); } return list; } } public UnlockReason GrantedReason { get; private set; } public bool HasCarriedUnlockRow { get; private set; } public bool NeedsQuarantineRewrite { get; private set; } public CompanionSidecar(CompanionScope scope) { if (!scope.IsComplete) { throw new ArgumentException("A companion sidecar needs a resolved product, world, and character.", "scope"); } Scope = scope; } public bool RecordUnlockGrant(UnlockReason reason) { if (reason == UnlockReason.NotUnlocked || HasCarriedUnlockRow || IsReadOnly) { return false; } if (GrantedReason != UnlockReason.NotUnlocked) { return false; } GrantedReason = reason; IsDirty = true; return true; } public bool TryGetQuest(QuestId questId, out CompanionQuestRecord record) { return _quests.TryGetValue(questId.Value, out record); } public bool ResetQuest(QuestId questId) { if (IsReadOnly || questId.IsEmpty || !_quests.Remove(questId.Value)) { return false; } _order.Remove(questId.Value); IsDirty = true; return true; } public CompanionQuestRecord GetOrCreate(QuestId questId) { if (questId.IsEmpty) { throw new ArgumentException("A quest record needs a valid quest id.", "questId"); } if (_quests.TryGetValue(questId.Value, out CompanionQuestRecord value)) { return value; } CompanionQuestRecord companionQuestRecord = new CompanionQuestRecord(questId, QuestState.Unstarted); _quests.Add(questId.Value, companionQuestRecord); _order.Add(questId.Value); return companionQuestRecord; } public QuestTransitionOutcome Apply(QuestId questId, QuestTransition transition) { QuestTransitionOutcome num = GetOrCreate(questId).Apply(transition); if (num == QuestTransitionOutcome.Advanced) { IsDirty = true; } return num; } public bool MarkPresentationRetired(QuestId questId) { if (!GetOrCreate(questId).MarkPresentationRetired()) { return false; } IsDirty = true; return true; } public bool MarkJoinAnnounced(QuestId questId) { if (IsReadOnly || !_quests.TryGetValue(questId.Value, out CompanionQuestRecord value) || !value.MarkJoinAnnounced()) { return false; } IsDirty = true; return true; } public bool HasAnyCompletedQuest() { foreach (string item in _order) { if (_quests[item].IsComplete) { return true; } } return false; } public void MarkClean() { IsDirty = false; } internal void MarkReadOnly() { IsReadOnly = true; HasForwardData = true; } internal void AddCarriedLine(string line, bool isForwardData) { if (isForwardData) { _forwardLines.Add(line); HasForwardData = true; } else { _quarantinedLines.Add(line); } } internal void ReadmitQuarantinedLine(string line) { _quarantinedLines.Add(line); HasPreviouslyCarriedDamage = true; } internal void RequestQuarantineRewrite() { if (!IsReadOnly) { NeedsQuarantineRewrite = true; } } internal void QuarantineRewritten() { NeedsQuarantineRewrite = false; } internal void RestoreGrant(UnlockReason reason) { if (GrantedReason == UnlockReason.NotUnlocked) { GrantedReason = reason; } } internal void MarkCarriedUnlockRow() { HasCarriedUnlockRow = true; } internal bool Restore(CompanionQuestRecord record) { if (_quests.ContainsKey(record.QuestId.Value)) { return false; } _quests.Add(record.QuestId.Value, record); _order.Add(record.QuestId.Value); return true; } } internal static class CompanionSidecarCodec { public sealed class ParseResult { public CompanionSidecar Sidecar { get; } public SidecarLoadOutcome Outcome { get; } public int SkippedRows { get; } public ParseResult(CompanionSidecar sidecar, SidecarLoadOutcome outcome, int skippedRows) { Sidecar = sidecar; Outcome = outcome; SkippedRows = skippedRows; } } private enum QuestRowOutcome { Parsed, ForwardStage, Malformed } public const string Header = "# ConcernedCatMods companions"; public const int SchemaVersion = 1; private const char FieldSeparator = '\t'; private const string SchemaRow = "s"; private const string ScopeRow = "k"; private const string QuestRow = "q"; private const string UnlockRow = "u"; private const string CarriedRow = "x"; private const int QuestFieldCount = 5; public static IEnumerable<string> Serialize(CompanionSidecar sidecar) { if (sidecar == null) { throw new ArgumentNullException("sidecar"); } yield return "# ConcernedCatMods companions v" + 1.ToString(CultureInfo.InvariantCulture); yield return "s\t" + 1.ToString(CultureInfo.InvariantCulture); yield return string.Join('\t'.ToString(), "k", sidecar.Scope.Product.Value, sidecar.Scope.World.ToStorageToken(), sidecar.Scope.Character.ToStorageToken()); if (sidecar.GrantedReason != UnlockReason.NotUnlocked) { yield return "u\t" + ((int)sidecar.GrantedReason).ToString(CultureInfo.InvariantCulture); } foreach (CompanionQuestRecord quest in sidecar.Quests) { yield return SerializeQuest(quest); } foreach (string forwardLine in sidecar.ForwardLines) { yield return forwardLine; } foreach (string quarantinedLine in sidecar.QuarantinedLines) { yield return "x\t" + quarantinedLine; } } public static string SerializeQuest(CompanionQuestRecord record) { List<string> list = new List<string>(5 + record.UnknownFields.Count) { "q", record.QuestId.Value, ((int)record.State).ToString(CultureInfo.InvariantCulture), record.Revision.ToString(CultureInfo.InvariantCulture), record.PresentationRetired ? "1" : "0", record.JoinAnnounced ? "1" : "0" }; foreach (string unknownField in record.UnknownFields) { list.Add(unknownField); } return string.Join('\t'.ToString(), list.ToArray()); } public static ParseResult Parse(IEnumerable<string>? lines, CompanionScope expected) { CompanionSidecar companionSidecar = new CompanionSidecar(expected); if (lines == null) { return new ParseResult(companionSidecar, SidecarLoadOutcome.Missing, 0); } List<string> list = new List<string>(); bool flag = false; int num = -1; bool flag2 = false; bool flag3 = false; int num2 = 0; foreach (string line in lines) { if (line == null) { continue; } string text = line.TrimEnd('\r', '\n'); if (text.Trim().Length == 0) { continue; } flag = true; if (text.StartsWith("#", StringComparison.Ordinal)) { continue; } string[] array = text.Split(new char[1] { '\t' }); if (array.Length >= 2 && string.Equals(array[0], "s", StringComparison.Ordinal)) { if (int.TryParse(array[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) { num = result; } } else if (array.Length >= 4 && string.Equals(array[0], "k", StringComparison.Ordinal)) { flag3 = ((!flag2) ? ScopeMatches(array, expected) : (flag3 && ScopeMatches(array, expected))); flag2 = true; } else { list.Add(text); } } if (!flag) { return new ParseResult(companionSidecar, SidecarLoadOutcome.Missing, 0); } if (num < 0 || !flag2) { return new ParseResult(companionSidecar, SidecarLoadOutcome.Corrupt, 0); } if (!flag3) { return new ParseResult(companionSidecar, SidecarLoadOutcome.ScopeMismatch, 0); } if (num > 1) { companionSidecar.MarkReadOnly(); return new ParseResult(companionSidecar, SidecarLoadOutcome.UnsupportedSchema, 0); } foreach (string item in list) { string[] array2 = item.Split(new char[1] { '\t' }); if (string.Equals(array2[0], "x", StringComparison.Ordinal)) { companionSidecar.ReadmitQuarantinedLine((array2.Length >= 2) ? item.Substring("x".Length + 1) : item); continue; } if (string.Equals(array2[0], "u", StringComparison.Ordinal)) { if (array2.Length >= 2 && int.TryParse(array2[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result2) && IsKnownReason(result2)) { companionSidecar.RestoreGrant((UnlockReason)result2); continue; } companionSidecar.MarkCarriedUnlockRow(); companionSidecar.AddCarriedLine(item, isForwardData: true); continue; } if (!string.Equals(array2[0], "q", StringComparison.Ordinal)) { companionSidecar.AddCarriedLine(item, isForwardData: true); continue; } CompanionQuestRecord record; switch (TryParseQuest(array2, out record)) { case QuestRowOutcome.ForwardStage: companionSidecar.MarkReadOnly(); companionSidecar.AddCarriedLine(item, isForwardData: true); continue; case QuestRowOutcome.Malformed: num2++; companionSidecar.AddCarriedLine(item, isForwardData: false); continue; } if (!companionSidecar.Restore(record)) { num2++; companionSidecar.AddCarriedLine(item, isForwardData: false); } } if (num2 > 0) { companionSidecar.RequestQuarantineRewrite(); } SidecarLoadOutcome outcome = ((num2 <= 0) ? SidecarLoadOutcome.Loaded : SidecarLoadOutcome.LoadedWithSkippedRows); return new ParseResult(companionSidecar, outcome, num2); } private static bool IsKnownReason(int value) { if ((uint)(value - 1) <= 5u) { return true; } return false; } private static bool ScopeMatches(string[] fields, CompanionScope expected) { if (!string.Equals(fields[1], expected.Product.Value, StringComparison.Ordinal)) { return false; } if (!WorldId.TryParseStorageToken(fields[2], out var id) || !id.Equals(expected.World)) { return false; } if (CharacterId.TryParseStorageToken(fields[3], out var id2)) { return id2.Equals(expected.Character); } return false; } private static QuestRowOutcome TryParseQuest(string[] fields, out CompanionQuestRecord? record) { record = null; if (fields.Length < 5) { return QuestRowOutcome.Malformed; } if (!QuestId.TryCreate(fields[1], out var id)) { return QuestRowOutcome.Malformed; } if (!int.TryParse(fields[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) { return QuestRowOutcome.Malformed; } if (!int.TryParse(fields[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result2)) { return QuestRowOutcome.Malformed; } QuestState state = (QuestState)result; if (!QuestStateMachine.IsKnown(state)) { return QuestRowOutcome.ForwardStage; } bool presentationRetired = string.Equals(fields[4], "1", StringComparison.Ordinal); int num = 5; bool joinAnnounced = false; if (fields.Length > 5 && (fields[5] == "0" || fields[5] == "1")) { joinAnnounced = fields[5] == "1"; num = 6; } List<string> list = new List<string>(); for (int i = num; i < fields.Length; i++) { list.Add(fields[i]); } record = CompanionQuestRecord.Restore(id, state, result2, presentationRetired, list, joinAnnounced); return QuestRowOutcome.Parsed; } } internal sealed class CompanionSidecarStore { public sealed class LoadReport { public CompanionSidecar Sidecar { get; } public SidecarLoadOutcome Outcome { get; } public int SkippedRows { get; } public string? Notice { get; } public string? QuarantinePath { get; } public LoadReport(CompanionSidecar sidecar, SidecarLoadOutcome outcome, int skippedRows, string? notice, string? quarantinePath) { Sidecar = sidecar; Outcome = outcome; SkippedRows = skippedRows; Notice = notice; QuarantinePath = quarantinePath; } } public sealed class SaveReport { public bool Saved { get; } public string? Notice { get; } public SaveReport(bool saved, string? notice) { Saved = saved; Notice = notice; } } private const string Extension = ".companions.tsv"; private const string TemporarySuffix = ".tmp"; private const string QuarantineSuffix = ".corrupt"; private const int MaxQuarantineAttempts = 20; private readonly string _rootDirectory; public CompanionSidecarStore(string rootDirectory) { if (string.IsNullOrEmpty(rootDirectory)) { throw new ArgumentException("A sidecar root directory is required.", "rootDirectory"); } _rootDirectory = rootDirectory; } public string ResolvePath(CompanionScope scope) { return Path.Combine(_rootDirectory, scope.ToStorageKey() + ".companions.tsv"); } public LoadReport Load(CompanionScope scope) { string path = ResolvePath(scope); string[] lines; try { if (!File.Exists(path)) { return new LoadReport(new CompanionSidecar(scope), SidecarLoadOutcome.Missing, 0, null, null); } lines = File.ReadAllLines(path); } catch (Exception exception) { CompanionSidecar companionSidecar = new CompanionSidecar(scope); companionSidecar.MarkReadOnly(); return new LoadReport(companionSidecar, SidecarLoadOutcome.Corrupt, 0, "Could not read your companion data (" + Describe(exception) + "). Your existing features stay available and nothing was deleted.", null); } CompanionSidecarCodec.ParseResult parseResult = CompanionSidecarCodec.Parse(lines, scope); switch (parseResult.Outcome) { case SidecarLoadOutcome.Corrupt: { string text = TryQuarantine(path); if (text == null) { parseResult.Sidecar.MarkReadOnly(); return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, "Your companion data could not be read and could not be moved aside, so it was left exactly as it is. Your existing features stay available and nothing was deleted.", null); } return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, "Your companion data could not be read. The old file was kept as \"" + Path.GetFileName(text) + "\" and a fresh one will be written. Your existing features stay available.", text); } case SidecarLoadOutcome.ScopeMismatch: parseResult.Sidecar.MarkReadOnly(); return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, "The companion data file \"" + Path.GetFileName(path) + "\" belongs to a different character or world, so it was left untouched and no new progress will be saved over it. Move or rename that file to start fresh here.", null); case SidecarLoadOutcome.UnsupportedSchema: return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, "Your companion data was written by a newer version of this mod. It is being left as it is so nothing gets lost, and your existing features stay available.", null); case SidecarLoadOutcome.LoadedWithSkippedRows: return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, parseResult.SkippedRows.ToString(CultureInfo.InvariantCulture) + " damaged companion entr" + ((parseResult.SkippedRows == 1) ? "y was" : "ies were") + " skipped. Everything readable was kept.", null); default: return new LoadReport(parseResult.Sidecar, parseResult.Outcome, parseResult.SkippedRows, null, null); } } public SaveReport Save(CompanionSidecar sidecar, bool force = false) { if (sidecar == null) { throw new ArgumentNullException("sidecar"); } if (sidecar.IsReadOnly) { return new SaveReport(saved: false, "Companion progress was not saved: the existing data file is from a newer version or another character, and overwriting it would lose data."); } if (!force && !sidecar.IsDirty) { return new SaveReport(saved: false, null); } string text = ResolvePath(sidecar.Scope); string text2 = text + ".tmp"; try { Directory.CreateDirectory(_rootDirectory); using StreamWriter streamWriter = new StreamWriter(text2, append: false); foreach (string item in CompanionSidecarCodec.Serialize(sidecar)) { streamWriter.WriteLine(item); } } catch (Exception exception) { TryDelete(text2); return new SaveReport(saved: false, DescribeSaveFailure(exception)); } try { Commit(text2, text); sidecar.MarkClean(); return new SaveReport(saved: true, null); } catch (Exception exception2) { return new SaveReport(saved: false, DescribeSaveFailure(exception2) + " A complete copy was left as \"" + Path.GetFileName(text2) + "\"."); } } private static string DescribeSaveFailure(Exception exception) { return "Could not save companion progress (" + Describe(exception) + "). Your existing features stay available and will be saved again on the next change."; } private static void Commit(string temporaryPath, string path) { if (!File.Exists(path)) { File.Move(temporaryPath, path); return; } try { File.Replace(temporaryPath, path, null); } catch (PlatformNotSupportedException) { CopyOver(temporaryPath, path); } catch (IOException) { CopyOver(temporaryPath, path); } } private static void CopyOver(string temporaryPath, string path) { File.Copy(temporaryPath, path, overwrite: true); TryDelete(temporaryPath); } private static string? TryQuarantine(string path) { for (int i = 0; i < 20; i++) { string text = ((i == 0) ? (path + ".corrupt") : (path + ".corrupt." + i.ToString(CultureInfo.InvariantCulture))); if (!File.Exists(text)) { try { File.Move(path, text); return text; } catch (IOException) { } catch (UnauthorizedAccessException) { return null; } } } return null; } private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { } } private static string Describe(Exception exception) { return exception.GetType().Name; } public bool HasSidecarFor(CompanionScope scope) { try { return File.Exists(ResolvePath(scope)); } catch { return false; } } public IReadOnlyList<string> ListSidecarFiles(ProductId product) { if (product.IsEmpty) { return new string[0]; } List<string> list = new List<string>(); string value = product.Value + "."; foreach (string item in ListSidecarFiles()) { if (Path.GetFileName(item).StartsWith(value, StringComparison.Ordinal)) { list.Add(item); } } return list; } public IReadOnlyList<string> ListSidecarFiles() { try { if (!Directory.Exists(_rootDirectory)) { return new string[0]; } return Directory.GetFiles(_rootDirectory, "*.companions.tsv"); } catch { return new string[0]; } } } internal sealed class DoorAccessStore { public sealed class LoadReport { public DoorAccessBook Book { get; } public bool ReadOnly { get; } public int Skipped { get; } public string? Notice { get; } public LoadReport(DoorAccessBook book, bool readOnly, int skipped, string? notice) { Book = book; ReadOnly = readOnly; Skipped = skipped; Notice = notice; } } private const string Prefix = "doors-"; private const string Extension = ".tsv"; private const string TemporarySuffix = ".tmp"; private readonly string _rootDirectory; public DoorAccessStore(string rootDirectory) { if (string.IsNullOrEmpty(rootDirectory)) { throw new ArgumentException("A door access root directory is required.", "rootDirectory"); } _rootDirectory = rootDirectory; } public string ResolvePath(WorldId world) { return Path.Combine(_rootDirectory, "doors-" + world.ToStorageToken() + ".tsv"); } public LoadReport Load(WorldId world) { string path = ResolvePath(world); try { if (!File.Exists(path)) { return new LoadReport(new DoorAccessBook(), readOnly: false, 0, null); } int skipped; return new LoadReport(DoorAccessCodec.Parse(File.ReadAllLines(path), out skipped), readOnly: false, skipped, (skipped == 0) ? null : (skipped.ToString(CultureInfo.InvariantCulture) + " damaged door entr" + ((skipped == 1) ? "y was" : "ies were") + " skipped; those doors are closed to companions until you allow them again.")); } catch (Exception ex) { return new LoadReport(new DoorAccessBook(), readOnly: true, 0, "Could not read which doors companions may use (" + ex.GetType().Name + "). Every door is closed to them this session, and the file was left as it is."); } } public string? Save(WorldId world, DoorAccessBook book, bool readOnly) { if (book == null) { throw new ArgumentNullException("book"); } if (!book.IsDirty) { return null; } if (readOnly) { return "The door list was not saved: the existing file could not be read, and saving over it could lose it."; } string text = ResolvePath(world); string text2 = text + ".tmp"; try { Directory.CreateDirectory(_rootDirectory); File.WriteAllLines(text2, new List<string>(DoorAccessCodec.Serialize(book))); if (File.Exists(text)) { try { File.Replace(text2, text, null); } catch (Exception ex) when (ex is IOException || ex is PlatformNotSupportedException) { File.Copy(text2, text, overwrite: true); File.Delete(text2); } } else { File.Move(text2, text); } book.MarkClean(); return null; } catch (Exception ex2) { return "The door list could not be saved (" + ex2.GetType().Name + "); the change holds for this session only."; } } } internal enum SidecarLoadOutcome { Missing, Loaded, LoadedWithSkippedRows, ScopeMismatch, UnsupportedSchema, Corrupt } internal static class SidecarLoadOutcomes { public static bool IndicatesPriorData(SidecarLoadOutcome outcome) { if ((uint)(outcome - 2) <= 3u) { return true; } return false; } } } namespace TheConcernedCat.Companions.Identity { internal readonly struct CharacterId : IEquatable<CharacterId> { public static readonly CharacterId None; public long Value { get; } public bool IsValid => Value != 0; public CharacterId(long value) { Value = value; } public string ToStorageToken() { return ((ulong)Value).ToString("x16", CultureInfo.InvariantCulture); } public static bool TryParseStorageToken(string? token, out CharacterId id) { id = None; if (string.IsNullOrEmpty(token) || token.Length != 16) { return false; } if (!ulong.TryParse(token, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result)) { return false; } id = new CharacterId((long)result); return true; } public bool Equals(CharacterId other) { return Value == other.Value; } public override bool Equals(object? obj) { if (obj is CharacterId other) { return Equals(other); } return false; } public override int GetHashCode() { return Value.GetHashCode(); } public override string ToString() { return ToStorageToken(); } } internal readonly struct CompanionId : IEquatable<CompanionId> { private readonly string? _value; public string Value => _value ?? string.Empty; public bool IsEmpty => string.IsNullOrEmpty(_value); public CompanionId(string value) { _value = IdentitySlug.Require(value, "value"); } public static bool TryCreate(string? value, out CompanionId id) { if (!IdentitySlug.IsValid(value)) { id = default(CompanionId); return false; } id = new CompanionId(value); return true; } public bool Equals(CompanionId other) { return string.Equals(Value, other.Value, StringComparison.Ordinal); } public override bool Equals(object? obj) { if (obj is CompanionId other) { return Equals(other); } return false; } public override int GetHashCode() { return Value.GetHashCode(); } public override string ToString() { return Value; } } internal readonly struct CompanionScope : IEquatable<CompanionScope> { public ProductId Product { get; } public WorldId World { get; } public CharacterId Character { get; } public bool IsComplete { get { if (!Product.IsEmpty && World.IsValid) { return Character.IsValid; } return false; } } public CompanionScope(ProductId product, WorldId world, CharacterId character) { Product = product; World = world; Character = character; } public string ToStorageKey() { if (!IsComplete) { throw new InvalidOperationException("A companion storage key requires a resolved product, world, and character."); } return Product.Value + "." + World.ToStorageToken() + "." + Character.ToStorageToken(); } public bool Equals(CompanionScope other) { if (Product.Equals(other.Product) && World.Equals(other.World)) { return Character.Equals(other.Character); } return false; } public override bool Equals(object? obj) { if (obj is CompanionScope other) { return Equals(other); } return false; } public override int GetHashCode() { return (((Product.GetHashCode() * 397) ^ World.GetHashCode()) * 397) ^ Character.GetHashCode(); } public override string ToString() { if (!IsComplete) { return "<incomplete companion scope>"; } return ToStorageKey(); } } internal static class IdentitySlug { public const int MaxLength = 48; public static bool IsValid(string? value) { if (string.IsNullOrEmpty(value) || value.Length > 48) { return false; } if (value[0] == '-' || value[value.Length - 1] == '-') { return false; } char c = '\0'; foreach (char c2 in value) { if ((c2 < 'a' || c2 > 'z') && (c2 < '0' || c2 > '9') && c2 != '-') { return false; } if (c2 == '-' && c == '-') { return false; } c = c2; } return true; } public static string Require(string? value, string parameterName) { if (!IsValid(value)) { throw new ArgumentException("Identity slugs must be 1-" + 48 + " characters of a-z, 0-9 or '-', without leading, trailing, or doubled dashes. Received: '" + (value ?? "<null>") + "'.", parameterName); } return value; } } internal readonly struct ProductId : IEquatable<ProductId> { private readonly string? _value; public string Value => _value ?? string.Empty; public bool IsEmpty => string.IsNullOrEmpty(_value); public ProductId(string value) { _value = IdentitySlug.Require(value, "value"); } public static bool TryCreate(string? value, out ProductId id) { if (!IdentitySlug.IsValid(value)) { id = default(ProductId); return false; } id = new ProductId(value); return true; } public bool Equals(ProductId other) { return string.Equals(Value, other.Value, StringComparison.Ordinal); } public override bool Equals(object? obj) { if (obj is ProductId other) { return Equals(other); } return false; } public override int GetHashCode() { return Value.GetHashCode(); } public override string ToString() { return Value; } } internal readonly struct QuestId : IEquatable<QuestId> { private readonly string? _value; public string Value => _value ?? string.Empty; public bool IsEmpty => string.IsNullOrEmpty(_value); public QuestId(string value) { _value = IdentitySlug.Require(value, "value"); } public static bool TryCreate(string? value, out QuestId id) { if (!IdentitySlug.IsValid(value)) { id = default(QuestId); return false; } id = new QuestId(value); return true; } public bool Equals(QuestId other) { return string.Equals(Value, other.Value, StringComparison.Ordinal); } public override bool Equals(object? obj) { if (obj is QuestId other) { return Equals(other); } return false; } public override int GetHashCode() { return Value.GetHashCode(); } public override string ToString() { return Value; } } internal readonly struct WorldId : IEquatable<WorldId> { public static readonly WorldId None; public long Value { get; } public bool IsValid => Value != 0; public WorldId(long value) { Value = value; } public string ToStorageToken() { return ((ulong)Value).ToString("x16", CultureInfo.InvariantCulture); } public static bool TryParseStorageToken(string? token, out WorldId id) { id = None; if (string.IsNullOrEmpty(token) || token.Length != 16) { return false; } if (!ulong.TryParse(token, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result)) { return false; } id = new WorldId((long)result); return true; } public bool Equals(WorldId other) { return Value == other.Value; } public override bool Equals(object? obj) { if (obj is WorldId other) { return Equals(other); } return false; } public override int GetHashCode() { return Value.GetHashCode(); } public override string ToString() { return ToStorageToken(); } } } namespace TheConcernedCat.Companions.Dialogue { internal sealed class DialogueCatalog { private readonly List<DialogueLine> _lines; public IReadOnlyList<DialogueLine> Lines => _lines; public int Count => _lines.Count; public DialogueCatalog(IEnumerable<DialogueLine> lines) { if (lines == null) { throw new ArgumentNullException("lines"); } _lines = new List<DialogueLine>(); HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); foreach (DialogueLine line in lines) { if (line != null) { if (!hashSet.Add(line.Key)) { throw new ArgumentException("Duplicate dialogue key in catalogue: " + line.Key, "lines"); } _lines.Add(line); } } } public int CountIn(DialogueCategory category) { int num = 0; foreach (DialogueLine line in _lines) { if (line.Category == category) { num++; } } return num; } public int IndexOf(DialogueLine? line) { if (line == null) { return -1; } for (int i = 0; i < _lines.Count; i++) { if (string.Equals(_lines[i].Key, line.Key, StringComparison.Ordinal)) { return i; } } return -1; } public List<DialogueLine> Eligible(DialogueCategory? category, DialogueContext context) { DialogueContext dialogueContext = context ?? DialogueContext.Empty; List<DialogueLine> list = new List<DialogueLine>(); foreach (DialogueLine line in _lines) { if ((!category.HasValue || line.Category == category.Value) && dialogueContext.Allows(line)) { list.Add(line); } } return list; } public IReadOnlyList<DialogueCategory> UnconditionalCategories() { List<DialogueCategory> list = new List<DialogueCategory>(); foreach (DialogueLine line in _lines) { if (line.RequiredBiome == null && !list.Contains(line.Category)) { list.Add(line.Category); } } return list; } } internal enum DialogueCategory { CatAndHome, Travel, Weather, BiomeTip, Greeting } internal sealed class DialogueContext { private readonly HashSet<string> _knownBiomes; public static DialogueContext Empty { get; } = new DialogueContext(); public int KnownBiomeCount => _knownBiomes.Count; public DialogueContext(IEnumerable<string>? knownBiomes = null) { _knownBiomes = new HashSet<string>(StringComparer.OrdinalIgnoreCase); if (knownBiomes == null) { return; } foreach (string knownBiome in knownBiomes) { if (!string.IsNullOrEmpty(knownBiome)) { _knownBiomes.Add(knownBiome); } } } public bool KnowsBiome(string? biome) { if (!string.IsNullOrEmpty(biome)) { return _knownBiomes.Contains(biome); } return false; } public bool Allows(DialogueLine line) { if (line.RequiredBiome != null) { return KnowsBiome(line.RequiredBiome); } return true; } } internal sealed class DialogueLine { public string Key { get; } public DialogueCategory Category { get; } public string? RequiredBiome { get; } public DialogueLine(string key, DialogueCategory category, string? requiredBiome = null) { if (string.IsNullOrEmpty(key)) { throw new ArgumentException("A dialogue line needs a localization key.", "key"); } Key = key; Category = category; RequiredBiome = (string.IsNullOrEmpty(requiredBiome) ? null : requiredBiome); } public override string ToString() { return Category.ToString() + ":" + Key; } } internal sealed class DialogueRotation { public const int DefaultSuppressionWindow = 8; private const int WholeCatalogue = -1; private readonly DialogueCatalog _catalog; private readonly int _suppressionWindow; private readonly List<string> _recent = new List<string>(); private readonly Dictionary<int, int> _cursors = new Dictionary<int, int>(); private readonly int _startOffset; public IReadOnlyList<string> RecentKeys => _recent; public DialogueRotation(DialogueCatalog catalog, int suppressionWindow = 8, int startOffset = 0) { _catalog = catalog ?? throw new ArgumentNullException("catalog"); if (suppressionWindow < 0) { throw new ArgumentOutOfRangeException("suppressionWindow"); } _suppressionWindow = suppressionWindow; _startOffset = (int)((catalog.Count != 0) ? ((uint)startOffset % (uint)catalog.Count) : 0); } public bool TryNext(DialogueCategory? category, DialogueContext context, out DialogueLine line) { line = null; List<DialogueLine> list = _catalog.Eligible(category, context); if (list.Count == 0) { return false; } int scope = (int)(category.HasValue ? category.Value : ((DialogueCategory)(-1))); DialogueLine dialogueLine = PickUnsuppressed(list, scope); if (dialogueLine == null) { ReleaseOldestUntilAvailable(list); dialogueLine = PickUnsuppressed(list, scope); if (dialogueLine == null) { dialogueLine = list[0]; AdvanceCursorPast(scope, dialogueLine); } } Remember(dialogueLine); line = dialogueLine; return true; } private DialogueLine? PickUnsuppressed(List<DialogueLine> eligible, int scope) { int num = FirstAtOrAfterCursor(eligible, CursorFor(scope)); for (int i = 0; i < eligible.Count; i++) { int index = (num + i) % eligible.Count; DialogueLine dialogueLine = eligible[index]; if (!_recent.Contains(dialogueLine.Key)) { AdvanceCursorPast(scope, dialogueLine); return dialogueLine; } } return null; } private int CursorFor(int scope) { if (!_cursors.TryGetValue(scope, out var value)) { return _startOffset; } return value; } private int FirstAtOrAfterCursor(List<DialogueLine> eligible, int cursor) { for (int i = 0; i < eligible.Count; i++) { if (_catalog.IndexOf(eligible[i]) >= cursor) { return i; } } return 0; } private void AdvanceCursorPast(int scope, DialogueLine chosen) { if (_catalog.Count == 0) { _cursors[scope] = 0; return; } int num = _catalog.IndexOf(chosen); _cursors[scope] = ((num >= 0) ? ((num + 1) % _catalog.Count) : 0); } private void ReleaseOldestUntilAvailable(List<DialogueLine> eligible) { while (_recent.Count > 0) { _recent.RemoveAt(0); foreach (DialogueLine item in eligible) { if (!_recent.Contains(item.Key)) { return; } } } } private void Remember(DialogueLine chosen) { _recent.Add(chosen.Key); while (_recent.Count > _suppressionWindow) { _recent.RemoveAt(0); } } public void Reset() { _recent.Clear(); } } } namespace TheConcernedCat.Companions.Definitions { internal sealed class CompanionDefinition { public ProductId Product { get; } public CompanionId Companion { get; } public QuestId IntroductionQuest { get; } public string NameKey { get; } public CompanionDefinition(ProductId product, CompanionId companion, QuestId introductionQuest, string nameKey) { if (product.IsEmpty) { throw new ArgumentException("A companion needs an owning product.", "product"); } if (companion.IsEmpty) { throw new ArgumentException("A companion needs an identity.", "companion"); } if (introductionQuest.IsEmpty) { throw new ArgumentException("A companion needs an introduction quest.", "introductionQuest"); } if (string.IsNullOrEmpty(nameKey)) { throw new ArgumentException("A companion needs a localization key for its name.", "nameKey"); } Product = product; Companion = companion; IntroductionQuest = introductionQuest; NameKey = nameKey; } public override string ToString() { return Product.Value + "/" + Companion.Value; } } internal sealed class CompanionRegistry { private readonly Dictionary<string, ICompanionProduct> _products = new Dictionary<string, ICompanionProduct>(StringComparer.Ordinal); private readonly Dictionary<string, CompanionDefinition> _companions = new Dictionary<string, CompanionDefinition>(StringComparer.Ordinal); private readonly Dictionary<string, QuestDefinition> _quests = new Dictionary<string, QuestDefinition>(StringComparer.Ordinal); public int ProductCount => _products.Count; public RegistrationOutcome Register(ICompanionProduct product) { if (product == null) { throw new ArgumentNullException("product"); } ProductId id = product.Id; if (id.IsEmpty) { return RegistrationOutcome.InvalidProduct; } if (_products.ContainsKey(id.Value)) { return RegistrationOutcome.DuplicateProduct; } IReadOnlyList<CompanionDefinition> readOnlyList = product.Companions ?? new CompanionDefinition[0]; IReadOnlyList<QuestDefinition> readOnlyList2 = product.Quests ?? new QuestDefinition[0]; HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); foreach (CompanionDefinition item in readOnlyList) { if (!item.Product.Equals(id)) { return RegistrationOutcome.InconsistentDefinitions; } if (!hashSet.Add(item.Companion.Value)) { return RegistrationOutcome.DuplicateCompanion; } } HashSet<string> hashSet2 = new HashSet<string>(StringComparer.Ordinal); foreach (QuestDefinition item2 in