Now that's a collection manager.

This commit is contained in:
Ottermandias 2023-04-06 15:47:33 +02:00
parent 5a817db069
commit f85fc46fb7
55 changed files with 2433 additions and 2317 deletions

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Penumbra.Services;
namespace Penumbra.Collections.Manager;
public static class ActiveCollectionMigration
{
/// <summary> Migrate ungendered collections to Male and Female for 0.5.9.0. </summary>
public static void MigrateUngenderedCollections(FilenameService fileNames)
{
if (!ActiveCollections.Load(fileNames, out var jObject))
return;
foreach (var (type, _, _) in CollectionTypeExtensions.Special.Where(t => t.Item2.StartsWith("Male ")))
{
var oldName = type.ToString()[4..];
var value = jObject[oldName];
if (value == null)
continue;
jObject.Remove(oldName);
jObject.Add("Male" + oldName, value);
jObject.Add("Female" + oldName, value);
}
using var stream = File.Open(fileNames.ActiveCollectionsFile, FileMode.Truncate);
using var writer = new StreamWriter(stream);
using var j = new JsonTextWriter(writer);
j.Formatting = Formatting.Indented;
jObject.WriteTo(j);
}
/// <summary> Migrate individual collections to Identifiers for 0.6.0. </summary>
public static bool MigrateIndividualCollections(CollectionStorage storage, IndividualCollections individuals, JObject jObject)
{
var version = jObject[nameof(Version)]?.Value<int>() ?? 0;
if (version > 0)
return false;
// Load character collections. If a player name comes up multiple times, the last one is applied.
var characters = jObject["Characters"]?.ToObject<Dictionary<string, string>>() ?? new Dictionary<string, string>();
var dict = new Dictionary<string, ModCollection>(characters.Count);
foreach (var (player, collectionName) in characters)
{
if (!storage.ByName(collectionName, out var collection))
{
Penumbra.ChatService.NotificationMessage(
$"Last choice of <{player}>'s Collection {collectionName} is not available, reset to {ModCollection.Empty.Name}.", "Load Failure",
NotificationType.Warning);
dict.Add(player, ModCollection.Empty);
}
else
{
dict.Add(player, collection);
}
}
individuals.Migrate0To1(dict);
return true;
}
}

View file

@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui;
using Penumbra.GameData.Actors;
using Penumbra.Services;
using Penumbra.UI;
using Penumbra.Util;
using static OtterGui.Raii.ImRaii;
namespace Penumbra.Collections.Manager;
public class ActiveCollections : ISavable, IDisposable
{
public const int Version = 1;
private readonly CollectionStorage _storage;
private readonly CommunicatorService _communicator;
private readonly SaveService _saveService;
public ActiveCollections(CollectionStorage storage, ActorService actors, CommunicatorService communicator, SaveService saveService)
{
_storage = storage;
_communicator = communicator;
_saveService = saveService;
Current = storage.DefaultNamed;
Default = storage.DefaultNamed;
Interface = storage.DefaultNamed;
Individuals = new IndividualCollections(actors.AwaitedService);
_communicator.CollectionChange.Subscribe(OnCollectionChange);
LoadCollections();
}
public void Dispose()
=> _communicator.CollectionChange.Unsubscribe(OnCollectionChange);
/// <summary> The collection currently selected for changing settings. </summary>
public ModCollection Current { get; private set; }
/// <summary> Whether the currently selected collection is used either directly via assignment or via inheritance. </summary>
public bool CurrentCollectionInUse { get; private set; }
/// <summary> The collection used for general file redirections and all characters not specifically named. </summary>
public ModCollection Default { get; private set; }
/// <summary> The collection used for all files categorized as UI files. </summary>
public ModCollection Interface { get; private set; }
/// <summary> The list of individual assignments. </summary>
public readonly IndividualCollections Individuals;
/// <summary> Get the collection assigned to an individual or Default if unassigned. </summary>
public ModCollection Individual(ActorIdentifier identifier)
=> Individuals.TryGetCollection(identifier, out var c) ? c : Default;
/// <summary> The list of group assignments. </summary>
private readonly ModCollection?[] _specialCollections = new ModCollection?[Enum.GetValues<Api.Enums.ApiCollectionType>().Length - 3];
/// <summary> Return all actually assigned group assignments. </summary>
public IEnumerable<KeyValuePair<CollectionType, ModCollection>> SpecialAssignments
{
get
{
for (var i = 0; i < _specialCollections.Length; ++i)
{
var collection = _specialCollections[i];
if (collection != null)
yield return new KeyValuePair<CollectionType, ModCollection>((CollectionType)i, collection);
}
}
}
/// <inheritdoc cref="ByType(CollectionType, ActorIdentifier)"/>
public ModCollection? ByType(CollectionType type)
=> ByType(type, ActorIdentifier.Invalid);
/// <summary> Return the configured collection for the given type or null. </summary>
public ModCollection? ByType(CollectionType type, ActorIdentifier identifier)
{
if (type.IsSpecial())
return _specialCollections[(int)type];
return type switch
{
CollectionType.Default => Default,
CollectionType.Interface => Interface,
CollectionType.Current => Current,
CollectionType.Individual => identifier.IsValid && Individuals.TryGetValue(identifier, out var c) ? c : null,
_ => null,
};
}
/// <summary> Create a special collection if it does not exist and set it to Empty. </summary>
public bool CreateSpecialCollection(CollectionType collectionType)
{
if (!collectionType.IsSpecial() || _specialCollections[(int)collectionType] != null)
return false;
_specialCollections[(int)collectionType] = Default;
_communicator.CollectionChange.Invoke(collectionType, null, Default, string.Empty);
return true;
}
/// <summary> Remove a special collection if it exists </summary>
public void RemoveSpecialCollection(CollectionType collectionType)
{
if (!collectionType.IsSpecial())
return;
var old = _specialCollections[(int)collectionType];
if (old == null)
return;
_specialCollections[(int)collectionType] = null;
_communicator.CollectionChange.Invoke(collectionType, old, null, string.Empty);
}
/// <summary>Create an individual collection if possible. </summary>
public void CreateIndividualCollection(params ActorIdentifier[] identifiers)
{
if (Individuals.Add(identifiers, Default))
_communicator.CollectionChange.Invoke(CollectionType.Individual, null, Default, Individuals.Last().DisplayName);
}
/// <summary> Remove an individual collection if it exists. </summary>
public void RemoveIndividualCollection(int individualIndex)
{
if (individualIndex < 0 || individualIndex >= Individuals.Count)
return;
var (name, old) = Individuals[individualIndex];
if (Individuals.Delete(individualIndex))
_communicator.CollectionChange.Invoke(CollectionType.Individual, old, null, name);
}
/// <summary> Move an individual collection from one index to another. </summary>
public void MoveIndividualCollection(int from, int to)
{
if (Individuals.Move(from, to))
_saveService.QueueSave(this);
}
/// <summary> Set a active collection, can be used to set Default, Current, Interface, Special, or Individual collections. </summary>
public void SetCollection(ModCollection collection, CollectionType collectionType, int individualIndex = -1)
{
var oldCollection = collectionType switch
{
CollectionType.Default => Default,
CollectionType.Interface => Interface,
CollectionType.Current => Current,
CollectionType.Individual when individualIndex >= 0 && individualIndex < Individuals.Count => Individuals[individualIndex].Collection,
CollectionType.Individual => null,
_ when collectionType.IsSpecial() => _specialCollections[(int)collectionType] ?? Default,
_ => null,
};
if (oldCollection == null || collection == oldCollection || collection.Index >= _storage.Count)
return;
switch (collectionType)
{
case CollectionType.Default:
Default = collection;
break;
case CollectionType.Interface:
Interface = collection;
break;
case CollectionType.Current:
Current = collection;
break;
case CollectionType.Individual:
if (!Individuals.ChangeCollection(individualIndex, collection))
return;
break;
default:
_specialCollections[(int)collectionType] = collection;
break;
}
UpdateCurrentCollectionInUse();
_communicator.CollectionChange.Invoke(collectionType, oldCollection, collection,
collectionType == CollectionType.Individual ? Individuals[individualIndex].DisplayName : string.Empty);
}
public string ToFilename(FilenameService fileNames)
=> fileNames.ActiveCollectionsFile;
public string TypeName
=> "Active Collections";
public string LogName(string _)
=> "to file";
public void Save(StreamWriter writer)
{
var jObj = new JObject
{
{ nameof(Version), Version },
{ nameof(Default), Default.Name },
{ nameof(Interface), Interface.Name },
{ nameof(Current), Current.Name },
};
foreach (var (type, collection) in _specialCollections.WithIndex().Where(p => p.Value != null)
.Select(p => ((CollectionType)p.Index, p.Value!)))
jObj.Add(type.ToString(), collection.Name);
jObj.Add(nameof(Individuals), Individuals.ToJObject());
using var j = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
jObj.WriteTo(j);
}
private void UpdateCurrentCollectionInUse()
=> CurrentCollectionInUse = _specialCollections
.OfType<ModCollection>()
.Prepend(Interface)
.Prepend(Default)
.Concat(Individuals.Assignments.Select(kvp => kvp.Collection))
.SelectMany(c => c.GetFlattenedInheritance()).Contains(Current);
/// <summary> Save if any of the active collections is changed and set new collections to Current. </summary>
private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, ModCollection? newCollection, string _3)
{
if (collectionType is CollectionType.Inactive)
{
if (newCollection != null)
{
SetCollection(newCollection, CollectionType.Current);
}
else if (oldCollection != null)
{
if (oldCollection == Default)
SetCollection(ModCollection.Empty, CollectionType.Default);
if (oldCollection == Interface)
SetCollection(ModCollection.Empty, CollectionType.Interface);
if (oldCollection == Current)
SetCollection(Default.Index > ModCollection.Empty.Index ? Default : _storage.DefaultNamed, CollectionType.Current);
for (var i = 0; i < _specialCollections.Length; ++i)
{
if (oldCollection == _specialCollections[i])
SetCollection(ModCollection.Empty, (CollectionType)i);
}
for (var i = 0; i < Individuals.Count; ++i)
{
if (oldCollection == Individuals[i].Collection)
SetCollection(ModCollection.Empty, CollectionType.Individual, i);
}
}
}
else if (collectionType is not CollectionType.Temporary)
{
_saveService.QueueSave(this);
}
}
/// <summary>
/// Load default, current, special, and character collections from config.
/// Then create caches. If a collection does not exist anymore, reset it to an appropriate default.
/// </summary>
private void LoadCollections()
{
var configChanged = !Load(_saveService.FileNames, out var jObject);
// Load the default collection. If the string does not exist take the Default name if no file existed or the Empty name if one existed.
var defaultName = jObject[nameof(Default)]?.ToObject<string>()
?? (configChanged ? ModCollection.DefaultCollectionName : ModCollection.Empty.Name);
if (!_storage.ByName(defaultName, out var defaultCollection))
{
Penumbra.ChatService.NotificationMessage(
$"Last choice of {TutorialService.DefaultCollection} {defaultName} is not available, reset to {ModCollection.Empty.Name}.",
"Load Failure",
NotificationType.Warning);
Default = ModCollection.Empty;
configChanged = true;
}
else
{
Default = defaultCollection;
}
// Load the interface collection. If no string is set, use the name of whatever was set as Default.
var interfaceName = jObject[nameof(Interface)]?.ToObject<string>() ?? Default.Name;
if (!_storage.ByName(interfaceName, out var interfaceCollection))
{
Penumbra.ChatService.NotificationMessage(
$"Last choice of {TutorialService.InterfaceCollection} {interfaceName} is not available, reset to {ModCollection.Empty.Name}.",
"Load Failure", NotificationType.Warning);
Interface = ModCollection.Empty;
configChanged = true;
}
else
{
Interface = interfaceCollection;
}
// Load the current collection.
var currentName = jObject[nameof(Current)]?.ToObject<string>() ?? Default.Name;
if (!_storage.ByName(currentName, out var currentCollection))
{
Penumbra.ChatService.NotificationMessage(
$"Last choice of {TutorialService.SelectedCollection} {currentName} is not available, reset to {ModCollection.DefaultCollectionName}.",
"Load Failure", NotificationType.Warning);
Current = _storage.DefaultNamed;
configChanged = true;
}
else
{
Current = currentCollection;
}
// Load special collections.
foreach (var (type, name, _) in CollectionTypeExtensions.Special)
{
var typeName = jObject[type.ToString()]?.ToObject<string>();
if (typeName != null)
{
if (!_storage.ByName(typeName, out var typeCollection))
{
Penumbra.ChatService.NotificationMessage($"Last choice of {name} Collection {typeName} is not available, removed.",
"Load Failure",
NotificationType.Warning);
configChanged = true;
}
else
{
_specialCollections[(int)type] = typeCollection;
}
}
}
configChanged |= ActiveCollectionMigration.MigrateIndividualCollections(_storage, Individuals, jObject);
configChanged |= Individuals.ReadJObject(jObject[nameof(Individuals)] as JArray, _storage);
// Save any changes and create all required caches.
if (configChanged)
_saveService.ImmediateSave(this);
}
/// <summary>
/// Read the active collection file into a jObject.
/// Returns true if this is successful, false if the file does not exist or it is unsuccessful.
/// </summary>
public static bool Load(FilenameService fileNames, out JObject ret)
{
var file = fileNames.ActiveCollectionsFile;
if (File.Exists(file))
try
{
ret = JObject.Parse(File.ReadAllText(file));
return true;
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not read active collections from file {file}:\n{e}");
}
ret = new JObject();
return false;
}
public string RedundancyCheck(CollectionType type, ActorIdentifier id)
{
var checkAssignment = ByType(type, id);
if (checkAssignment == null)
return string.Empty;
switch (type)
{
// Check individual assignments. We can only be sure of redundancy for world-overlap or ownership overlap.
case CollectionType.Individual:
switch (id.Type)
{
case IdentifierType.Player when id.HomeWorld != ushort.MaxValue:
{
var global = ByType(CollectionType.Individual, Penumbra.Actors.CreatePlayer(id.PlayerName, ushort.MaxValue));
return global?.Index == checkAssignment.Index
? "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it."
: string.Empty;
}
case IdentifierType.Owned:
if (id.HomeWorld != ushort.MaxValue)
{
var global = ByType(CollectionType.Individual,
Penumbra.Actors.CreateOwned(id.PlayerName, ushort.MaxValue, id.Kind, id.DataId));
if (global?.Index == checkAssignment.Index)
return "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it.";
}
var unowned = ByType(CollectionType.Individual, Penumbra.Actors.CreateNpc(id.Kind, id.DataId));
return unowned?.Index == checkAssignment.Index
? "Assignment is redundant due to an identical unowned NPC assignment existing.\nYou can remove it."
: string.Empty;
}
break;
// The group of all Characters is redundant if they are all equal to Default or unassigned.
case CollectionType.MalePlayerCharacter:
case CollectionType.MaleNonPlayerCharacter:
case CollectionType.FemalePlayerCharacter:
case CollectionType.FemaleNonPlayerCharacter:
var first = ByType(CollectionType.MalePlayerCharacter) ?? Default;
var second = ByType(CollectionType.MaleNonPlayerCharacter) ?? Default;
var third = ByType(CollectionType.FemalePlayerCharacter) ?? Default;
var fourth = ByType(CollectionType.FemaleNonPlayerCharacter) ?? Default;
if (first.Index == second.Index
&& first.Index == third.Index
&& first.Index == fourth.Index
&& first.Index == Default.Index)
return
"Assignment is currently redundant due to the group [Male, Female, Player, NPC] Characters being unassigned or identical to each other and Default.\n"
+ "You can keep just the Default Assignment.";
break;
// Children and Elderly are redundant if they are identical to both Male NPCs and Female NPCs, or if they are unassigned to Default.
case CollectionType.NonPlayerChild:
case CollectionType.NonPlayerElderly:
var maleNpc = ByType(CollectionType.MaleNonPlayerCharacter);
var femaleNpc = ByType(CollectionType.FemaleNonPlayerCharacter);
var collection1 = CollectionType.MaleNonPlayerCharacter;
var collection2 = CollectionType.FemaleNonPlayerCharacter;
if (maleNpc == null)
{
maleNpc = Default;
if (maleNpc.Index != checkAssignment.Index)
return string.Empty;
collection1 = CollectionType.Default;
}
if (femaleNpc == null)
{
femaleNpc = Default;
if (femaleNpc.Index != checkAssignment.Index)
return string.Empty;
collection2 = CollectionType.Default;
}
return collection1 == collection2
? $"Assignment is currently redundant due to overwriting {collection1.ToName()} with an identical collection.\nYou can remove them."
: $"Assignment is currently redundant due to overwriting {collection1.ToName()} and {collection2.ToName()} with an identical collection.\nYou can remove them.";
// For other assignments, check the inheritance order, unassigned means fall-through,
// assigned needs identical assignments to be redundant.
default:
var group = type.InheritanceOrder();
foreach (var parentType in group)
{
var assignment = ByType(parentType);
if (assignment == null)
continue;
if (assignment.Index == checkAssignment.Index)
return
$"Assignment is currently redundant due to overwriting {parentType.ToName()} with an identical collection.\nYou can remove it.";
}
break;
}
return string.Empty;
}
}

View file

@ -0,0 +1,173 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Penumbra.Api;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
namespace Penumbra.Collections.Manager;
public class CollectionCacheManager : IDisposable, IReadOnlyDictionary<ModCollection, ModCollectionCache>
{
private readonly ActiveCollections _active;
private readonly CommunicatorService _communicator;
private readonly Dictionary<ModCollection, ModCollectionCache> _cache = new();
public int Count
=> _cache.Count;
public IEnumerator<KeyValuePair<ModCollection, ModCollectionCache>> GetEnumerator()
=> _cache.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public bool ContainsKey(ModCollection key)
=> _cache.ContainsKey(key);
public bool TryGetValue(ModCollection key, [NotNullWhen(true)] out ModCollectionCache? value)
=> _cache.TryGetValue(key, out value);
public ModCollectionCache this[ModCollection key]
=> _cache[key];
public IEnumerable<ModCollection> Keys
=> _cache.Keys;
public IEnumerable<ModCollectionCache> Values
=> _cache.Values;
public IEnumerable<ModCollection> Active
=> _cache.Keys.Where(c => c.Index > ModCollection.Empty.Index);
public CollectionCacheManager(ActiveCollections active, CommunicatorService communicator)
{
_active = active;
_communicator = communicator;
_communicator.CollectionChange.Subscribe(OnCollectionChange);
_communicator.ModPathChanged.Subscribe(OnModChangeAddition, -100);
_communicator.ModPathChanged.Subscribe(OnModChangeRemoval, 100);
_communicator.TemporaryGlobalModChange.Subscribe(OnGlobalModChange);
_communicator.ModOptionChanged.Subscribe(OnModOptionChange, -100);
CreateNecessaryCaches();
}
public void Dispose()
{
_communicator.CollectionChange.Unsubscribe(OnCollectionChange);
_communicator.ModPathChanged.Unsubscribe(OnModChangeAddition);
_communicator.ModPathChanged.Unsubscribe(OnModChangeRemoval);
_communicator.TemporaryGlobalModChange.Unsubscribe(OnGlobalModChange);
_communicator.ModOptionChanged.Unsubscribe(OnModOptionChange);
}
/// <summary>
/// Cache handling. Usually recreate caches on the next framework tick,
/// but at launch create all of them at once.
/// </summary>
public void CreateNecessaryCaches()
{
var tasks = _active.SpecialAssignments.Select(p => p.Value)
.Concat(_active.Individuals.Select(p => p.Collection))
.Prepend(_active.Current)
.Prepend(_active.Default)
.Prepend(_active.Interface)
.Distinct()
.Select(c => Task.Run(() => c.CalculateEffectiveFileListInternal(c == _active.Default)))
.ToArray();
Task.WaitAll(tasks);
}
private void OnCollectionChange(CollectionType type, ModCollection? old, ModCollection? newCollection, string displayName)
{
if (type is CollectionType.Inactive)
return;
var isDefault = type is CollectionType.Default;
if (newCollection?.Index > ModCollection.Empty.Index)
{
newCollection.CreateCache(isDefault);
_cache.TryAdd(newCollection, newCollection._cache!);
}
RemoveCache(old);
}
private void OnModChangeRemoval(ModPathChangeType type, Mod mod, DirectoryInfo? oldModPath, DirectoryInfo? newModPath)
{
switch (type)
{
case ModPathChangeType.Deleted:
case ModPathChangeType.StartingReload:
foreach (var collection in _cache.Keys.Where(c => c[mod.Index].Settings?.Enabled == true))
collection._cache!.RemoveMod(mod, true);
break;
case ModPathChangeType.Moved:
foreach (var collection in _cache.Keys.Where(c => c.HasCache && c[mod.Index].Settings?.Enabled == true))
collection._cache!.ReloadMod(mod, true);
break;
}
}
private void OnModChangeAddition(ModPathChangeType type, Mod mod, DirectoryInfo? oldModPath, DirectoryInfo? newModPath)
{
if (type is not (ModPathChangeType.Added or ModPathChangeType.Reloaded))
return;
foreach (var collection in _cache.Keys.Where(c => c[mod.Index].Settings?.Enabled == true))
collection._cache!.AddMod(mod, true);
}
/// <summary> Apply a mod change to all collections with a cache. </summary>
private void OnGlobalModChange(TemporaryMod mod, bool created, bool removed)
=> TempModManager.OnGlobalModChange(_cache.Keys, mod, created, removed);
/// <summary> Remove a cache from a collection if it is active. </summary>
private void RemoveCache(ModCollection? collection)
{
if (collection != null
&& collection.Index > ModCollection.Empty.Index
&& collection.Index != _active.Default.Index
&& collection.Index != _active.Interface.Index
&& collection.Index != _active.Current.Index
&& _active.SpecialAssignments.All(c => c.Value.Index != collection.Index)
&& _active.Individuals.All(c => c.Collection.Index != collection.Index))
{
_cache.Remove(collection);
collection.ClearCache();
}
}
/// <summary> Prepare Changes by removing mods from caches with collections or add or reload mods. </summary>
private void OnModOptionChange(ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx)
{
if (type is ModOptionChangeType.PrepareChange)
{
foreach (var collection in _cache.Keys.Where(collection => collection[mod.Index].Settings is { Enabled: true }))
collection._cache!.RemoveMod(mod, false);
return;
}
type.HandlingInfo(out _, out var recomputeList, out var reload);
if (!recomputeList)
return;
foreach (var collection in _cache.Keys.Where(collection => collection[mod.Index].Settings is { Enabled: true }))
{
if (reload)
collection._cache!.ReloadMod(mod, true);
else
collection._cache!.AddMod(mod, true);
}
}
}

View file

@ -0,0 +1,20 @@
namespace Penumbra.Collections.Manager;
public class CollectionManager
{
public readonly CollectionStorage Storage;
public readonly ActiveCollections Active;
public readonly InheritanceManager Inheritances;
public readonly CollectionCacheManager Caches;
public readonly TempCollectionManager Temp;
public CollectionManager(CollectionStorage storage, ActiveCollections active, InheritanceManager inheritances,
CollectionCacheManager caches, TempCollectionManager temp)
{
Storage = storage;
Active = active;
Inheritances = inheritances;
Caches = caches;
Temp = temp;
}
}

View file

@ -0,0 +1,306 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Dalamud.Interface.Internal.Notifications;
using OtterGui;
using OtterGui.Filesystem;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Collections.Manager;
public class CollectionStorage : IReadOnlyList<ModCollection>, IDisposable
{
private readonly CommunicatorService _communicator;
private readonly SaveService _saveService;
/// <remarks> The empty collection is always available at Index 0. </remarks>
private readonly List<ModCollection> _collections = new()
{
ModCollection.Empty,
};
public readonly ModCollection DefaultNamed;
/// <summary> Default enumeration skips the empty collection. </summary>
public IEnumerator<ModCollection> GetEnumerator()
=> _collections.Skip(1).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public IEnumerator<ModCollection> GetEnumeratorWithEmpty()
=> _collections.GetEnumerator();
public int Count
=> _collections.Count;
public ModCollection this[int index]
=> _collections[index];
/// <summary> Find a collection by its name. If the name is empty or None, the empty collection is returned. </summary>
public bool ByName(string name, [NotNullWhen(true)] out ModCollection? collection)
{
if (name.Length != 0)
return _collections.FindFirst(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase), out collection);
collection = ModCollection.Empty;
return true;
}
public CollectionStorage(CommunicatorService communicator, SaveService saveService)
{
_communicator = communicator;
_saveService = saveService;
_communicator.ModDiscoveryStarted.Subscribe(OnModDiscoveryStarted);
_communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished);
_communicator.ModPathChanged.Subscribe(OnModPathChange, 10);
_communicator.ModOptionChanged.Subscribe(OnModOptionChange, 100);
ReadCollections(out DefaultNamed);
}
public void Dispose()
{
_communicator.ModDiscoveryStarted.Unsubscribe(OnModDiscoveryStarted);
_communicator.ModDiscoveryFinished.Unsubscribe(OnModDiscoveryFinished);
_communicator.ModPathChanged.Unsubscribe(OnModPathChange);
_communicator.ModOptionChanged.Unsubscribe(OnModOptionChange);
}
/// <summary>
/// Returns true if the name is not empty, it is not the name of the empty collection
/// and no existing collection results in the same filename as name. Also returns the fixed name.
/// </summary>
public bool CanAddCollection(string name, out string fixedName)
{
if (!IsValidName(name))
{
fixedName = string.Empty;
return false;
}
name = name.ToLowerInvariant();
if (name.Length == 0
|| name == ModCollection.Empty.Name.ToLowerInvariant()
|| _collections.Any(c => c.Name.ToLowerInvariant() == name))
{
fixedName = string.Empty;
return false;
}
fixedName = name;
return true;
}
/// <summary>
/// Add a new collection of the given name.
/// If duplicate is not-null, the new collection will be a duplicate of it.
/// If the name of the collection would result in an already existing filename, skip it.
/// Returns true if the collection was successfully created and fires a Inactive event.
/// Also sets the current collection to the new collection afterwards.
/// </summary>
public bool AddCollection(string name, ModCollection? duplicate)
{
if (!CanAddCollection(name, out var fixedName))
{
Penumbra.ChatService.NotificationMessage(
$"The new collection {name} would lead to the same path {fixedName} as one that already exists.", "Warning",
NotificationType.Warning);
return false;
}
var newCollection = duplicate?.Duplicate(name) ?? ModCollection.CreateNewEmpty(name);
newCollection.Index = _collections.Count;
_collections.Add(newCollection);
_saveService.ImmediateSave(newCollection);
Penumbra.ChatService.NotificationMessage($"Created new collection {newCollection.AnonymizedName}.", "Success",
NotificationType.Success);
_communicator.CollectionChange.Invoke(CollectionType.Inactive, null, newCollection, string.Empty);
return true;
}
/// <summary> Whether the given collection can be deleted. </summary>
public bool CanRemoveCollection(ModCollection collection)
=> collection.Index > ModCollection.Empty.Index && collection.Index < Count && collection.Index != DefaultNamed.Index;
/// <summary>
/// Remove the given collection if it exists and is neither the empty nor the default-named collection.
/// </summary>
public bool RemoveCollection(ModCollection collection)
{
if (collection.Index <= ModCollection.Empty.Index || collection.Index >= _collections.Count)
{
Penumbra.ChatService.NotificationMessage("Can not remove the empty collection.", "Error", NotificationType.Error);
return false;
}
if (collection.Index == DefaultNamed.Index)
{
Penumbra.ChatService.NotificationMessage("Can not remove the default collection.", "Error", NotificationType.Error);
return false;
}
_saveService.ImmediateDelete(collection);
_collections.RemoveAt(collection.Index);
// Update indices.
for (var i = collection.Index; i < Count; ++i)
_collections[i].Index = i;
Penumbra.ChatService.NotificationMessage($"Deleted collection {collection.AnonymizedName}.", "Success", NotificationType.Success);
_communicator.CollectionChange.Invoke(CollectionType.Inactive, collection, null, string.Empty);
return true;
}
/// <summary> Stored after loading to be consumed and passed to the inheritance manager later. </summary>
private List<IReadOnlyList<string>>? _inheritancesByName = new();
/// <summary> Return an enumerable of collections and the collections they should inherit. </summary>
public IEnumerable<(ModCollection Collection, IReadOnlyList<ModCollection> Inheritance, bool LoadChanges)> ConsumeInheritanceNames()
{
if (_inheritancesByName == null)
throw new Exception("Inheritances were already consumed. This method can not be called twice.");
var inheritances = _inheritancesByName;
_inheritancesByName = null;
var list = new List<ModCollection>();
foreach (var (collection, inheritance) in _collections.Zip(inheritances))
{
list.Clear();
var changes = false;
foreach (var subCollectionName in inheritance)
{
if (ByName(subCollectionName, out var subCollection))
{
list.Add(subCollection);
}
else
{
Penumbra.ChatService.NotificationMessage(
$"Inherited collection {subCollectionName} for {collection.AnonymizedName} does not exist, it was removed.", "Warning",
NotificationType.Warning);
changes = true;
}
}
yield return (collection, list, changes);
}
}
/// <summary>
/// Check if a name is valid to use for a collection.
/// Does not check for uniqueness.
/// </summary>
private static bool IsValidName(string name)
=> name.Length > 0 && name.All(c => !c.IsInvalidAscii() && c is not '|' && !c.IsInvalidInPath());
/// <summary>
/// Read all collection files in the Collection Directory.
/// Ensure that the default named collection exists, and apply inheritances afterwards.
/// Duplicate collection files are not deleted, just not added here.
/// </summary>
private void ReadCollections(out ModCollection defaultNamedCollection)
{
_inheritancesByName?.Clear();
_inheritancesByName?.Add(Array.Empty<string>()); // None.
foreach (var file in _saveService.FileNames.CollectionFiles)
{
var collection = ModCollection.LoadFromFile(file, out var inheritance);
if (collection == null || collection.Name.Length == 0)
continue;
if (ByName(collection.Name, out _))
{
Penumbra.ChatService.NotificationMessage($"Duplicate collection found: {collection.Name} already exists. Import skipped.",
"Warning", NotificationType.Warning);
continue;
}
var correctName = _saveService.FileNames.CollectionFile(collection);
if (file.FullName != correctName)
Penumbra.ChatService.NotificationMessage($"Collection {file.Name} does not correspond to {collection.Name}.", "Warning",
NotificationType.Warning);
_inheritancesByName?.Add(inheritance);
collection.Index = _collections.Count;
_collections.Add(collection);
}
defaultNamedCollection = SetDefaultNamedCollection();
}
/// <summary>
/// Add the collection with the default name if it does not exist.
/// It should always be ensured that it exists, otherwise it will be created.
/// This can also not be deleted, so there are always at least the empty and a collection with default name.
/// </summary>
private ModCollection SetDefaultNamedCollection()
{
if (ByName(ModCollection.DefaultCollectionName, out var collection))
return collection;
if (AddCollection(ModCollection.DefaultCollectionName, null))
return _collections[^1];
Penumbra.ChatService.NotificationMessage(
$"Unknown problem creating a collection with the name {ModCollection.DefaultCollectionName}, which is required to exist.", "Error",
NotificationType.Error);
return Count > 1 ? _collections[1] : _collections[0];
}
/// <summary> Move all settings in all collections to unused settings. </summary>
private void OnModDiscoveryStarted()
{
foreach (var collection in this)
collection.PrepareModDiscovery();
}
/// <summary> Restore all settings in all collections to mods. </summary>
private void OnModDiscoveryFinished()
{
// Re-apply all mod settings.
foreach (var collection in this)
collection.ApplyModSettings();
}
/// <summary> Add or remove a mod from all collections, or re-save all collections where the mod has settings. </summary>
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory,
DirectoryInfo? newDirectory)
{
switch (type)
{
case ModPathChangeType.Added:
foreach (var collection in this)
collection.AddMod(mod);
break;
case ModPathChangeType.Deleted:
foreach (var collection in this)
collection.RemoveMod(mod, mod.Index);
break;
case ModPathChangeType.Moved:
foreach (var collection in this.Where(collection => collection.Settings[mod.Index] != null))
_saveService.QueueSave(collection);
break;
}
}
/// <summary> Save all collections where the mod has settings and the change requires saving. </summary>
private void OnModOptionChange(ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx)
{
type.HandlingInfo(out var requiresSaving, out _, out _);
if (!requiresSaving)
return;
foreach (var collection in this)
{
if (collection._settings[mod.Index]?.HandleChanges(type, mod, groupIdx, optionIdx, movedToIdx) ?? false)
_saveService.QueueSave(collection);
}
}
}

View file

@ -0,0 +1,583 @@
using Penumbra.GameData.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Penumbra.Collections.Manager;
public enum CollectionType : byte
{
// Special Collections
Yourself = Api.Enums.ApiCollectionType.Yourself,
MalePlayerCharacter = Api.Enums.ApiCollectionType.MalePlayerCharacter,
FemalePlayerCharacter = Api.Enums.ApiCollectionType.FemalePlayerCharacter,
MaleNonPlayerCharacter = Api.Enums.ApiCollectionType.MaleNonPlayerCharacter,
FemaleNonPlayerCharacter = Api.Enums.ApiCollectionType.FemaleNonPlayerCharacter,
NonPlayerChild = Api.Enums.ApiCollectionType.NonPlayerChild,
NonPlayerElderly = Api.Enums.ApiCollectionType.NonPlayerElderly,
MaleMidlander = Api.Enums.ApiCollectionType.MaleMidlander,
FemaleMidlander = Api.Enums.ApiCollectionType.FemaleMidlander,
MaleHighlander = Api.Enums.ApiCollectionType.MaleHighlander,
FemaleHighlander = Api.Enums.ApiCollectionType.FemaleHighlander,
MaleWildwood = Api.Enums.ApiCollectionType.MaleWildwood,
FemaleWildwood = Api.Enums.ApiCollectionType.FemaleWildwood,
MaleDuskwight = Api.Enums.ApiCollectionType.MaleDuskwight,
FemaleDuskwight = Api.Enums.ApiCollectionType.FemaleDuskwight,
MalePlainsfolk = Api.Enums.ApiCollectionType.MalePlainsfolk,
FemalePlainsfolk = Api.Enums.ApiCollectionType.FemalePlainsfolk,
MaleDunesfolk = Api.Enums.ApiCollectionType.MaleDunesfolk,
FemaleDunesfolk = Api.Enums.ApiCollectionType.FemaleDunesfolk,
MaleSeekerOfTheSun = Api.Enums.ApiCollectionType.MaleSeekerOfTheSun,
FemaleSeekerOfTheSun = Api.Enums.ApiCollectionType.FemaleSeekerOfTheSun,
MaleKeeperOfTheMoon = Api.Enums.ApiCollectionType.MaleKeeperOfTheMoon,
FemaleKeeperOfTheMoon = Api.Enums.ApiCollectionType.FemaleKeeperOfTheMoon,
MaleSeawolf = Api.Enums.ApiCollectionType.MaleSeawolf,
FemaleSeawolf = Api.Enums.ApiCollectionType.FemaleSeawolf,
MaleHellsguard = Api.Enums.ApiCollectionType.MaleHellsguard,
FemaleHellsguard = Api.Enums.ApiCollectionType.FemaleHellsguard,
MaleRaen = Api.Enums.ApiCollectionType.MaleRaen,
FemaleRaen = Api.Enums.ApiCollectionType.FemaleRaen,
MaleXaela = Api.Enums.ApiCollectionType.MaleXaela,
FemaleXaela = Api.Enums.ApiCollectionType.FemaleXaela,
MaleHelion = Api.Enums.ApiCollectionType.MaleHelion,
FemaleHelion = Api.Enums.ApiCollectionType.FemaleHelion,
MaleLost = Api.Enums.ApiCollectionType.MaleLost,
FemaleLost = Api.Enums.ApiCollectionType.FemaleLost,
MaleRava = Api.Enums.ApiCollectionType.MaleRava,
FemaleRava = Api.Enums.ApiCollectionType.FemaleRava,
MaleVeena = Api.Enums.ApiCollectionType.MaleVeena,
FemaleVeena = Api.Enums.ApiCollectionType.FemaleVeena,
MaleMidlanderNpc = Api.Enums.ApiCollectionType.MaleMidlanderNpc,
FemaleMidlanderNpc = Api.Enums.ApiCollectionType.FemaleMidlanderNpc,
MaleHighlanderNpc = Api.Enums.ApiCollectionType.MaleHighlanderNpc,
FemaleHighlanderNpc = Api.Enums.ApiCollectionType.FemaleHighlanderNpc,
MaleWildwoodNpc = Api.Enums.ApiCollectionType.MaleWildwoodNpc,
FemaleWildwoodNpc = Api.Enums.ApiCollectionType.FemaleWildwoodNpc,
MaleDuskwightNpc = Api.Enums.ApiCollectionType.MaleDuskwightNpc,
FemaleDuskwightNpc = Api.Enums.ApiCollectionType.FemaleDuskwightNpc,
MalePlainsfolkNpc = Api.Enums.ApiCollectionType.MalePlainsfolkNpc,
FemalePlainsfolkNpc = Api.Enums.ApiCollectionType.FemalePlainsfolkNpc,
MaleDunesfolkNpc = Api.Enums.ApiCollectionType.MaleDunesfolkNpc,
FemaleDunesfolkNpc = Api.Enums.ApiCollectionType.FemaleDunesfolkNpc,
MaleSeekerOfTheSunNpc = Api.Enums.ApiCollectionType.MaleSeekerOfTheSunNpc,
FemaleSeekerOfTheSunNpc = Api.Enums.ApiCollectionType.FemaleSeekerOfTheSunNpc,
MaleKeeperOfTheMoonNpc = Api.Enums.ApiCollectionType.MaleKeeperOfTheMoonNpc,
FemaleKeeperOfTheMoonNpc = Api.Enums.ApiCollectionType.FemaleKeeperOfTheMoonNpc,
MaleSeawolfNpc = Api.Enums.ApiCollectionType.MaleSeawolfNpc,
FemaleSeawolfNpc = Api.Enums.ApiCollectionType.FemaleSeawolfNpc,
MaleHellsguardNpc = Api.Enums.ApiCollectionType.MaleHellsguardNpc,
FemaleHellsguardNpc = Api.Enums.ApiCollectionType.FemaleHellsguardNpc,
MaleRaenNpc = Api.Enums.ApiCollectionType.MaleRaenNpc,
FemaleRaenNpc = Api.Enums.ApiCollectionType.FemaleRaenNpc,
MaleXaelaNpc = Api.Enums.ApiCollectionType.MaleXaelaNpc,
FemaleXaelaNpc = Api.Enums.ApiCollectionType.FemaleXaelaNpc,
MaleHelionNpc = Api.Enums.ApiCollectionType.MaleHelionNpc,
FemaleHelionNpc = Api.Enums.ApiCollectionType.FemaleHelionNpc,
MaleLostNpc = Api.Enums.ApiCollectionType.MaleLostNpc,
FemaleLostNpc = Api.Enums.ApiCollectionType.FemaleLostNpc,
MaleRavaNpc = Api.Enums.ApiCollectionType.MaleRavaNpc,
FemaleRavaNpc = Api.Enums.ApiCollectionType.FemaleRavaNpc,
MaleVeenaNpc = Api.Enums.ApiCollectionType.MaleVeenaNpc,
FemaleVeenaNpc = Api.Enums.ApiCollectionType.FemaleVeenaNpc,
Default = Api.Enums.ApiCollectionType.Default, // The default collection was changed
Interface = Api.Enums.ApiCollectionType.Interface, // The ui collection was changed
Current = Api.Enums.ApiCollectionType.Current, // The current collection was changed
Individual, // An individual collection was changed
Inactive, // A collection was added or removed
Temporary, // A temporary collections was set or deleted via IPC
}
public static class CollectionTypeExtensions
{
public static bool IsSpecial(this CollectionType collectionType)
=> collectionType < CollectionType.Default;
public static readonly (CollectionType, string, string)[] Special = Enum.GetValues<CollectionType>()
.Where(IsSpecial)
.Select(s => (s, s.ToName(), s.ToDescription()))
.ToArray();
public static CollectionType FromParts(Gender gender, bool npc)
{
gender = gender switch
{
Gender.MaleNpc => Gender.Male,
Gender.FemaleNpc => Gender.Female,
_ => gender,
};
return (gender, npc) switch
{
(Gender.Male, false) => CollectionType.MalePlayerCharacter,
(Gender.Female, false) => CollectionType.FemalePlayerCharacter,
(Gender.Male, true) => CollectionType.MaleNonPlayerCharacter,
(Gender.Female, true) => CollectionType.FemaleNonPlayerCharacter,
_ => CollectionType.Inactive,
};
}
// @formatter:off
private static readonly IReadOnlyList<CollectionType> DefaultList = new[] { CollectionType.Default };
private static readonly IReadOnlyList<CollectionType> MalePlayerList = new[] { CollectionType.MalePlayerCharacter, CollectionType.Default };
private static readonly IReadOnlyList<CollectionType> FemalePlayerList = new[] { CollectionType.FemalePlayerCharacter, CollectionType.Default };
private static readonly IReadOnlyList<CollectionType> MaleNpcList = new[] { CollectionType.MaleNonPlayerCharacter, CollectionType.Default };
private static readonly IReadOnlyList<CollectionType> FemaleNpcList = new[] { CollectionType.FemaleNonPlayerCharacter, CollectionType.Default };
// @formatter:on
/// <summary> A list of definite redundancy possibilities. </summary>
public static IReadOnlyList<CollectionType> InheritanceOrder(this CollectionType collectionType)
=> collectionType switch
{
CollectionType.Yourself => DefaultList,
CollectionType.MalePlayerCharacter => DefaultList,
CollectionType.FemalePlayerCharacter => DefaultList,
CollectionType.MaleNonPlayerCharacter => DefaultList,
CollectionType.FemaleNonPlayerCharacter => DefaultList,
CollectionType.MaleMidlander => MalePlayerList,
CollectionType.FemaleMidlander => FemalePlayerList,
CollectionType.MaleHighlander => MalePlayerList,
CollectionType.FemaleHighlander => FemalePlayerList,
CollectionType.MaleWildwood => MalePlayerList,
CollectionType.FemaleWildwood => FemalePlayerList,
CollectionType.MaleDuskwight => MalePlayerList,
CollectionType.FemaleDuskwight => FemalePlayerList,
CollectionType.MalePlainsfolk => MalePlayerList,
CollectionType.FemalePlainsfolk => FemalePlayerList,
CollectionType.MaleDunesfolk => MalePlayerList,
CollectionType.FemaleDunesfolk => FemalePlayerList,
CollectionType.MaleSeekerOfTheSun => MalePlayerList,
CollectionType.FemaleSeekerOfTheSun => FemalePlayerList,
CollectionType.MaleKeeperOfTheMoon => MalePlayerList,
CollectionType.FemaleKeeperOfTheMoon => FemalePlayerList,
CollectionType.MaleSeawolf => MalePlayerList,
CollectionType.FemaleSeawolf => FemalePlayerList,
CollectionType.MaleHellsguard => MalePlayerList,
CollectionType.FemaleHellsguard => FemalePlayerList,
CollectionType.MaleRaen => MalePlayerList,
CollectionType.FemaleRaen => FemalePlayerList,
CollectionType.MaleXaela => MalePlayerList,
CollectionType.FemaleXaela => FemalePlayerList,
CollectionType.MaleHelion => MalePlayerList,
CollectionType.FemaleHelion => FemalePlayerList,
CollectionType.MaleLost => MalePlayerList,
CollectionType.FemaleLost => FemalePlayerList,
CollectionType.MaleRava => MalePlayerList,
CollectionType.FemaleRava => FemalePlayerList,
CollectionType.MaleVeena => MalePlayerList,
CollectionType.FemaleVeena => FemalePlayerList,
CollectionType.MaleMidlanderNpc => MaleNpcList,
CollectionType.FemaleMidlanderNpc => FemaleNpcList,
CollectionType.MaleHighlanderNpc => MaleNpcList,
CollectionType.FemaleHighlanderNpc => FemaleNpcList,
CollectionType.MaleWildwoodNpc => MaleNpcList,
CollectionType.FemaleWildwoodNpc => FemaleNpcList,
CollectionType.MaleDuskwightNpc => MaleNpcList,
CollectionType.FemaleDuskwightNpc => FemaleNpcList,
CollectionType.MalePlainsfolkNpc => MaleNpcList,
CollectionType.FemalePlainsfolkNpc => FemaleNpcList,
CollectionType.MaleDunesfolkNpc => MaleNpcList,
CollectionType.FemaleDunesfolkNpc => FemaleNpcList,
CollectionType.MaleSeekerOfTheSunNpc => MaleNpcList,
CollectionType.FemaleSeekerOfTheSunNpc => FemaleNpcList,
CollectionType.MaleKeeperOfTheMoonNpc => MaleNpcList,
CollectionType.FemaleKeeperOfTheMoonNpc => FemaleNpcList,
CollectionType.MaleSeawolfNpc => MaleNpcList,
CollectionType.FemaleSeawolfNpc => FemaleNpcList,
CollectionType.MaleHellsguardNpc => MaleNpcList,
CollectionType.FemaleHellsguardNpc => FemaleNpcList,
CollectionType.MaleRaenNpc => MaleNpcList,
CollectionType.FemaleRaenNpc => FemaleNpcList,
CollectionType.MaleXaelaNpc => MaleNpcList,
CollectionType.FemaleXaelaNpc => FemaleNpcList,
CollectionType.MaleHelionNpc => MaleNpcList,
CollectionType.FemaleHelionNpc => FemaleNpcList,
CollectionType.MaleLostNpc => MaleNpcList,
CollectionType.FemaleLostNpc => FemaleNpcList,
CollectionType.MaleRavaNpc => MaleNpcList,
CollectionType.FemaleRavaNpc => FemaleNpcList,
CollectionType.MaleVeenaNpc => MaleNpcList,
CollectionType.FemaleVeenaNpc => FemaleNpcList,
CollectionType.Individual => DefaultList,
_ => Array.Empty<CollectionType>(),
};
public static CollectionType FromParts(SubRace race, Gender gender, bool npc)
{
gender = gender switch
{
Gender.MaleNpc => Gender.Male,
Gender.FemaleNpc => Gender.Female,
_ => gender,
};
return (race, gender, npc) switch
{
(SubRace.Midlander, Gender.Male, false) => CollectionType.MaleMidlander,
(SubRace.Highlander, Gender.Male, false) => CollectionType.MaleHighlander,
(SubRace.Wildwood, Gender.Male, false) => CollectionType.MaleWildwood,
(SubRace.Duskwight, Gender.Male, false) => CollectionType.MaleDuskwight,
(SubRace.Plainsfolk, Gender.Male, false) => CollectionType.MalePlainsfolk,
(SubRace.Dunesfolk, Gender.Male, false) => CollectionType.MaleDunesfolk,
(SubRace.SeekerOfTheSun, Gender.Male, false) => CollectionType.MaleSeekerOfTheSun,
(SubRace.KeeperOfTheMoon, Gender.Male, false) => CollectionType.MaleKeeperOfTheMoon,
(SubRace.Seawolf, Gender.Male, false) => CollectionType.MaleSeawolf,
(SubRace.Hellsguard, Gender.Male, false) => CollectionType.MaleHellsguard,
(SubRace.Raen, Gender.Male, false) => CollectionType.MaleRaen,
(SubRace.Xaela, Gender.Male, false) => CollectionType.MaleXaela,
(SubRace.Helion, Gender.Male, false) => CollectionType.MaleHelion,
(SubRace.Lost, Gender.Male, false) => CollectionType.MaleLost,
(SubRace.Rava, Gender.Male, false) => CollectionType.MaleRava,
(SubRace.Veena, Gender.Male, false) => CollectionType.MaleVeena,
(SubRace.Midlander, Gender.Female, false) => CollectionType.FemaleMidlander,
(SubRace.Highlander, Gender.Female, false) => CollectionType.FemaleHighlander,
(SubRace.Wildwood, Gender.Female, false) => CollectionType.FemaleWildwood,
(SubRace.Duskwight, Gender.Female, false) => CollectionType.FemaleDuskwight,
(SubRace.Plainsfolk, Gender.Female, false) => CollectionType.FemalePlainsfolk,
(SubRace.Dunesfolk, Gender.Female, false) => CollectionType.FemaleDunesfolk,
(SubRace.SeekerOfTheSun, Gender.Female, false) => CollectionType.FemaleSeekerOfTheSun,
(SubRace.KeeperOfTheMoon, Gender.Female, false) => CollectionType.FemaleKeeperOfTheMoon,
(SubRace.Seawolf, Gender.Female, false) => CollectionType.FemaleSeawolf,
(SubRace.Hellsguard, Gender.Female, false) => CollectionType.FemaleHellsguard,
(SubRace.Raen, Gender.Female, false) => CollectionType.FemaleRaen,
(SubRace.Xaela, Gender.Female, false) => CollectionType.FemaleXaela,
(SubRace.Helion, Gender.Female, false) => CollectionType.FemaleHelion,
(SubRace.Lost, Gender.Female, false) => CollectionType.FemaleLost,
(SubRace.Rava, Gender.Female, false) => CollectionType.FemaleRava,
(SubRace.Veena, Gender.Female, false) => CollectionType.FemaleVeena,
(SubRace.Midlander, Gender.Male, true) => CollectionType.MaleMidlanderNpc,
(SubRace.Highlander, Gender.Male, true) => CollectionType.MaleHighlanderNpc,
(SubRace.Wildwood, Gender.Male, true) => CollectionType.MaleWildwoodNpc,
(SubRace.Duskwight, Gender.Male, true) => CollectionType.MaleDuskwightNpc,
(SubRace.Plainsfolk, Gender.Male, true) => CollectionType.MalePlainsfolkNpc,
(SubRace.Dunesfolk, Gender.Male, true) => CollectionType.MaleDunesfolkNpc,
(SubRace.SeekerOfTheSun, Gender.Male, true) => CollectionType.MaleSeekerOfTheSunNpc,
(SubRace.KeeperOfTheMoon, Gender.Male, true) => CollectionType.MaleKeeperOfTheMoonNpc,
(SubRace.Seawolf, Gender.Male, true) => CollectionType.MaleSeawolfNpc,
(SubRace.Hellsguard, Gender.Male, true) => CollectionType.MaleHellsguardNpc,
(SubRace.Raen, Gender.Male, true) => CollectionType.MaleRaenNpc,
(SubRace.Xaela, Gender.Male, true) => CollectionType.MaleXaelaNpc,
(SubRace.Helion, Gender.Male, true) => CollectionType.MaleHelionNpc,
(SubRace.Lost, Gender.Male, true) => CollectionType.MaleLostNpc,
(SubRace.Rava, Gender.Male, true) => CollectionType.MaleRavaNpc,
(SubRace.Veena, Gender.Male, true) => CollectionType.MaleVeenaNpc,
(SubRace.Midlander, Gender.Female, true) => CollectionType.FemaleMidlanderNpc,
(SubRace.Highlander, Gender.Female, true) => CollectionType.FemaleHighlanderNpc,
(SubRace.Wildwood, Gender.Female, true) => CollectionType.FemaleWildwoodNpc,
(SubRace.Duskwight, Gender.Female, true) => CollectionType.FemaleDuskwightNpc,
(SubRace.Plainsfolk, Gender.Female, true) => CollectionType.FemalePlainsfolkNpc,
(SubRace.Dunesfolk, Gender.Female, true) => CollectionType.FemaleDunesfolkNpc,
(SubRace.SeekerOfTheSun, Gender.Female, true) => CollectionType.FemaleSeekerOfTheSunNpc,
(SubRace.KeeperOfTheMoon, Gender.Female, true) => CollectionType.FemaleKeeperOfTheMoonNpc,
(SubRace.Seawolf, Gender.Female, true) => CollectionType.FemaleSeawolfNpc,
(SubRace.Hellsguard, Gender.Female, true) => CollectionType.FemaleHellsguardNpc,
(SubRace.Raen, Gender.Female, true) => CollectionType.FemaleRaenNpc,
(SubRace.Xaela, Gender.Female, true) => CollectionType.FemaleXaelaNpc,
(SubRace.Helion, Gender.Female, true) => CollectionType.FemaleHelionNpc,
(SubRace.Lost, Gender.Female, true) => CollectionType.FemaleLostNpc,
(SubRace.Rava, Gender.Female, true) => CollectionType.FemaleRavaNpc,
(SubRace.Veena, Gender.Female, true) => CollectionType.FemaleVeenaNpc,
_ => CollectionType.Inactive,
};
}
public static bool TryParse(string text, out CollectionType type)
{
if (Enum.TryParse(text, true, out type))
{
return type is not CollectionType.Inactive and not CollectionType.Temporary;
}
if (string.Equals(text, "character", StringComparison.OrdinalIgnoreCase))
{
type = CollectionType.Individual;
return true;
}
if (string.Equals(text, "base", StringComparison.OrdinalIgnoreCase))
{
type = CollectionType.Default;
return true;
}
if (string.Equals(text, "ui", StringComparison.OrdinalIgnoreCase))
{
type = CollectionType.Interface;
return true;
}
if (string.Equals(text, "selected", StringComparison.OrdinalIgnoreCase))
{
type = CollectionType.Current;
return true;
}
foreach (var t in Enum.GetValues<CollectionType>())
{
if (t is CollectionType.Inactive or CollectionType.Temporary)
{
continue;
}
if (string.Equals(text, t.ToName(), StringComparison.OrdinalIgnoreCase))
{
type = t;
return true;
}
}
return false;
}
public static string ToName(this CollectionType collectionType)
=> collectionType switch
{
CollectionType.Yourself => "Your Character",
CollectionType.NonPlayerChild => "Non-Player Children",
CollectionType.NonPlayerElderly => "Non-Player Elderly",
CollectionType.MalePlayerCharacter => "Male Player Characters",
CollectionType.MaleNonPlayerCharacter => "Male Non-Player Characters",
CollectionType.MaleMidlander => $"Male {SubRace.Midlander.ToName()}",
CollectionType.MaleHighlander => $"Male {SubRace.Highlander.ToName()}",
CollectionType.MaleWildwood => $"Male {SubRace.Wildwood.ToName()}",
CollectionType.MaleDuskwight => $"Male {SubRace.Duskwight.ToName()}",
CollectionType.MalePlainsfolk => $"Male {SubRace.Plainsfolk.ToName()}",
CollectionType.MaleDunesfolk => $"Male {SubRace.Dunesfolk.ToName()}",
CollectionType.MaleSeekerOfTheSun => $"Male {SubRace.SeekerOfTheSun.ToName()}",
CollectionType.MaleKeeperOfTheMoon => $"Male {SubRace.KeeperOfTheMoon.ToName()}",
CollectionType.MaleSeawolf => $"Male {SubRace.Seawolf.ToName()}",
CollectionType.MaleHellsguard => $"Male {SubRace.Hellsguard.ToName()}",
CollectionType.MaleRaen => $"Male {SubRace.Raen.ToName()}",
CollectionType.MaleXaela => $"Male {SubRace.Xaela.ToName()}",
CollectionType.MaleHelion => $"Male {SubRace.Helion.ToName()}",
CollectionType.MaleLost => $"Male {SubRace.Lost.ToName()}",
CollectionType.MaleRava => $"Male {SubRace.Rava.ToName()}",
CollectionType.MaleVeena => $"Male {SubRace.Veena.ToName()}",
CollectionType.MaleMidlanderNpc => $"Male {SubRace.Midlander.ToName()} (NPC)",
CollectionType.MaleHighlanderNpc => $"Male {SubRace.Highlander.ToName()} (NPC)",
CollectionType.MaleWildwoodNpc => $"Male {SubRace.Wildwood.ToName()} (NPC)",
CollectionType.MaleDuskwightNpc => $"Male {SubRace.Duskwight.ToName()} (NPC)",
CollectionType.MalePlainsfolkNpc => $"Male {SubRace.Plainsfolk.ToName()} (NPC)",
CollectionType.MaleDunesfolkNpc => $"Male {SubRace.Dunesfolk.ToName()} (NPC)",
CollectionType.MaleSeekerOfTheSunNpc => $"Male {SubRace.SeekerOfTheSun.ToName()} (NPC)",
CollectionType.MaleKeeperOfTheMoonNpc => $"Male {SubRace.KeeperOfTheMoon.ToName()} (NPC)",
CollectionType.MaleSeawolfNpc => $"Male {SubRace.Seawolf.ToName()} (NPC)",
CollectionType.MaleHellsguardNpc => $"Male {SubRace.Hellsguard.ToName()} (NPC)",
CollectionType.MaleRaenNpc => $"Male {SubRace.Raen.ToName()} (NPC)",
CollectionType.MaleXaelaNpc => $"Male {SubRace.Xaela.ToName()} (NPC)",
CollectionType.MaleHelionNpc => $"Male {SubRace.Helion.ToName()} (NPC)",
CollectionType.MaleLostNpc => $"Male {SubRace.Lost.ToName()} (NPC)",
CollectionType.MaleRavaNpc => $"Male {SubRace.Rava.ToName()} (NPC)",
CollectionType.MaleVeenaNpc => $"Male {SubRace.Veena.ToName()} (NPC)",
CollectionType.FemalePlayerCharacter => "Female Player Characters",
CollectionType.FemaleNonPlayerCharacter => "Female Non-Player Characters",
CollectionType.FemaleMidlander => $"Female {SubRace.Midlander.ToName()}",
CollectionType.FemaleHighlander => $"Female {SubRace.Highlander.ToName()}",
CollectionType.FemaleWildwood => $"Female {SubRace.Wildwood.ToName()}",
CollectionType.FemaleDuskwight => $"Female {SubRace.Duskwight.ToName()}",
CollectionType.FemalePlainsfolk => $"Female {SubRace.Plainsfolk.ToName()}",
CollectionType.FemaleDunesfolk => $"Female {SubRace.Dunesfolk.ToName()}",
CollectionType.FemaleSeekerOfTheSun => $"Female {SubRace.SeekerOfTheSun.ToName()}",
CollectionType.FemaleKeeperOfTheMoon => $"Female {SubRace.KeeperOfTheMoon.ToName()}",
CollectionType.FemaleSeawolf => $"Female {SubRace.Seawolf.ToName()}",
CollectionType.FemaleHellsguard => $"Female {SubRace.Hellsguard.ToName()}",
CollectionType.FemaleRaen => $"Female {SubRace.Raen.ToName()}",
CollectionType.FemaleXaela => $"Female {SubRace.Xaela.ToName()}",
CollectionType.FemaleHelion => $"Female {SubRace.Helion.ToName()}",
CollectionType.FemaleLost => $"Female {SubRace.Lost.ToName()}",
CollectionType.FemaleRava => $"Female {SubRace.Rava.ToName()}",
CollectionType.FemaleVeena => $"Female {SubRace.Veena.ToName()}",
CollectionType.FemaleMidlanderNpc => $"Female {SubRace.Midlander.ToName()} (NPC)",
CollectionType.FemaleHighlanderNpc => $"Female {SubRace.Highlander.ToName()} (NPC)",
CollectionType.FemaleWildwoodNpc => $"Female {SubRace.Wildwood.ToName()} (NPC)",
CollectionType.FemaleDuskwightNpc => $"Female {SubRace.Duskwight.ToName()} (NPC)",
CollectionType.FemalePlainsfolkNpc => $"Female {SubRace.Plainsfolk.ToName()} (NPC)",
CollectionType.FemaleDunesfolkNpc => $"Female {SubRace.Dunesfolk.ToName()} (NPC)",
CollectionType.FemaleSeekerOfTheSunNpc => $"Female {SubRace.SeekerOfTheSun.ToName()} (NPC)",
CollectionType.FemaleKeeperOfTheMoonNpc => $"Female {SubRace.KeeperOfTheMoon.ToName()} (NPC)",
CollectionType.FemaleSeawolfNpc => $"Female {SubRace.Seawolf.ToName()} (NPC)",
CollectionType.FemaleHellsguardNpc => $"Female {SubRace.Hellsguard.ToName()} (NPC)",
CollectionType.FemaleRaenNpc => $"Female {SubRace.Raen.ToName()} (NPC)",
CollectionType.FemaleXaelaNpc => $"Female {SubRace.Xaela.ToName()} (NPC)",
CollectionType.FemaleHelionNpc => $"Female {SubRace.Helion.ToName()} (NPC)",
CollectionType.FemaleLostNpc => $"Female {SubRace.Lost.ToName()} (NPC)",
CollectionType.FemaleRavaNpc => $"Female {SubRace.Rava.ToName()} (NPC)",
CollectionType.FemaleVeenaNpc => $"Female {SubRace.Veena.ToName()} (NPC)",
CollectionType.Inactive => "Collection",
CollectionType.Default => "Default",
CollectionType.Interface => "Interface",
CollectionType.Individual => "Individual",
CollectionType.Current => "Current",
_ => string.Empty,
};
public static string ToDescription(this CollectionType collectionType)
=> collectionType switch
{
CollectionType.Yourself => "This collection applies to your own character, regardless of its name.\n"
+ "It takes precedence before all other collections except for explicitly named individual collections.",
CollectionType.NonPlayerChild =>
"This collection applies to all non-player characters with a child body-type.\n"
+ "It takes precedence before all other collections except for explicitly named individual collections.",
CollectionType.NonPlayerElderly =>
"This collection applies to all non-player characters with an elderly body-type.\n"
+ "It takes precedence before all other collections except for explicitly named individual collections.",
CollectionType.MalePlayerCharacter =>
"This collection applies to all male player characters that do not have a more specific character or racial collections associated.",
CollectionType.MaleNonPlayerCharacter =>
"This collection applies to all human male non-player characters except those explicitly named. It takes precedence before the default and racial collections.",
CollectionType.MaleMidlander =>
"This collection applies to all male player character Midlander Hyur that do not have a more specific character collection associated.",
CollectionType.MaleHighlander =>
"This collection applies to all male player character Highlander Hyur that do not have a more specific character collection associated.",
CollectionType.MaleWildwood =>
"This collection applies to all male player character Wildwood Elezen that do not have a more specific character collection associated.",
CollectionType.MaleDuskwight =>
"This collection applies to all male player character Duskwight Elezen that do not have a more specific character collection associated.",
CollectionType.MalePlainsfolk =>
"This collection applies to all male player character Plainsfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.MaleDunesfolk =>
"This collection applies to all male player character Dunesfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.MaleSeekerOfTheSun =>
"This collection applies to all male player character Seekers of the Sun that do not have a more specific character collection associated.",
CollectionType.MaleKeeperOfTheMoon =>
"This collection applies to all male player character Keepers of the Moon that do not have a more specific character collection associated.",
CollectionType.MaleSeawolf =>
"This collection applies to all male player character Sea Wolf Roegadyn that do not have a more specific character collection associated.",
CollectionType.MaleHellsguard =>
"This collection applies to all male player character Hellsguard Roegadyn that do not have a more specific character collection associated.",
CollectionType.MaleRaen =>
"This collection applies to all male player character Raen Au Ra that do not have a more specific character collection associated.",
CollectionType.MaleXaela =>
"This collection applies to all male player character Xaela Au Ra that do not have a more specific character collection associated.",
CollectionType.MaleHelion =>
"This collection applies to all male player character Helion Hrothgar that do not have a more specific character collection associated.",
CollectionType.MaleLost =>
"This collection applies to all male player character Lost Hrothgar that do not have a more specific character collection associated.",
CollectionType.MaleRava =>
"This collection applies to all male player character Rava Viera that do not have a more specific character collection associated.",
CollectionType.MaleVeena =>
"This collection applies to all male player character Veena Viera that do not have a more specific character collection associated.",
CollectionType.MaleMidlanderNpc =>
"This collection applies to all male non-player character Midlander Hyur that do not have a more specific character collection associated.",
CollectionType.MaleHighlanderNpc =>
"This collection applies to all male non-player character Highlander Hyur that do not have a more specific character collection associated.",
CollectionType.MaleWildwoodNpc =>
"This collection applies to all male non-player character Wildwood Elezen that do not have a more specific character collection associated.",
CollectionType.MaleDuskwightNpc =>
"This collection applies to all male non-player character Duskwight Elezen that do not have a more specific character collection associated.",
CollectionType.MalePlainsfolkNpc =>
"This collection applies to all male non-player character Plainsfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.MaleDunesfolkNpc =>
"This collection applies to all male non-player character Dunesfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.MaleSeekerOfTheSunNpc =>
"This collection applies to all male non-player character Seekers of the Sun that do not have a more specific character collection associated.",
CollectionType.MaleKeeperOfTheMoonNpc =>
"This collection applies to all male non-player character Keepers of the Moon that do not have a more specific character collection associated.",
CollectionType.MaleSeawolfNpc =>
"This collection applies to all male non-player character Sea Wolf Roegadyn that do not have a more specific character collection associated.",
CollectionType.MaleHellsguardNpc =>
"This collection applies to all male non-player character Hellsguard Roegadyn that do not have a more specific character collection associated.",
CollectionType.MaleRaenNpc =>
"This collection applies to all male non-player character Raen Au Ra that do not have a more specific character collection associated.",
CollectionType.MaleXaelaNpc =>
"This collection applies to all male non-player character Xaela Au Ra that do not have a more specific character collection associated.",
CollectionType.MaleHelionNpc =>
"This collection applies to all male non-player character Helion Hrothgar that do not have a more specific character collection associated.",
CollectionType.MaleLostNpc =>
"This collection applies to all male non-player character Lost Hrothgar that do not have a more specific character collection associated.",
CollectionType.MaleRavaNpc =>
"This collection applies to all male non-player character Rava Viera that do not have a more specific character collection associated.",
CollectionType.MaleVeenaNpc =>
"This collection applies to all male non-player character Veena Viera that do not have a more specific character collection associated.",
CollectionType.FemalePlayerCharacter =>
"This collection applies to all female player characters that do not have a more specific character or racial collections associated.",
CollectionType.FemaleNonPlayerCharacter =>
"This collection applies to all human female non-player characters except those explicitly named. It takes precedence before the default and racial collections.",
CollectionType.FemaleMidlander =>
"This collection applies to all female player character Midlander Hyur that do not have a more specific character collection associated.",
CollectionType.FemaleHighlander =>
"This collection applies to all female player character Highlander Hyur that do not have a more specific character collection associated.",
CollectionType.FemaleWildwood =>
"This collection applies to all female player character Wildwood Elezen that do not have a more specific character collection associated.",
CollectionType.FemaleDuskwight =>
"This collection applies to all female player character Duskwight Elezen that do not have a more specific character collection associated.",
CollectionType.FemalePlainsfolk =>
"This collection applies to all female player character Plainsfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.FemaleDunesfolk =>
"This collection applies to all female player character Dunesfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.FemaleSeekerOfTheSun =>
"This collection applies to all female player character Seekers of the Sun that do not have a more specific character collection associated.",
CollectionType.FemaleKeeperOfTheMoon =>
"This collection applies to all female player character Keepers of the Moon that do not have a more specific character collection associated.",
CollectionType.FemaleSeawolf =>
"This collection applies to all female player character Sea Wolf Roegadyn that do not have a more specific character collection associated.",
CollectionType.FemaleHellsguard =>
"This collection applies to all female player character Hellsguard Roegadyn that do not have a more specific character collection associated.",
CollectionType.FemaleRaen =>
"This collection applies to all female player character Raen Au Ra that do not have a more specific character collection associated.",
CollectionType.FemaleXaela =>
"This collection applies to all female player character Xaela Au Ra that do not have a more specific character collection associated.",
CollectionType.FemaleHelion =>
"This collection applies to all female player character Helion Hrothgar that do not have a more specific character collection associated.",
CollectionType.FemaleLost =>
"This collection applies to all female player character Lost Hrothgar that do not have a more specific character collection associated.",
CollectionType.FemaleRava =>
"This collection applies to all female player character Rava Viera that do not have a more specific character collection associated.",
CollectionType.FemaleVeena =>
"This collection applies to all female player character Veena Viera that do not have a more specific character collection associated.",
CollectionType.FemaleMidlanderNpc =>
"This collection applies to all female non-player character Midlander Hyur that do not have a more specific character collection associated.",
CollectionType.FemaleHighlanderNpc =>
"This collection applies to all female non-player character Highlander Hyur that do not have a more specific character collection associated.",
CollectionType.FemaleWildwoodNpc =>
"This collection applies to all female non-player character Wildwood Elezen that do not have a more specific character collection associated.",
CollectionType.FemaleDuskwightNpc =>
"This collection applies to all female non-player character Duskwight Elezen that do not have a more specific character collection associated.",
CollectionType.FemalePlainsfolkNpc =>
"This collection applies to all female non-player character Plainsfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.FemaleDunesfolkNpc =>
"This collection applies to all female non-player character Dunesfolk Lalafell that do not have a more specific character collection associated.",
CollectionType.FemaleSeekerOfTheSunNpc =>
"This collection applies to all female non-player character Seekers of the Sun that do not have a more specific character collection associated.",
CollectionType.FemaleKeeperOfTheMoonNpc =>
"This collection applies to all female non-player character Keepers of the Moon that do not have a more specific character collection associated.",
CollectionType.FemaleSeawolfNpc =>
"This collection applies to all female non-player character Sea Wolf Roegadyn that do not have a more specific character collection associated.",
CollectionType.FemaleHellsguardNpc =>
"This collection applies to all female non-player character Hellsguard Roegadyn that do not have a more specific character collection associated.",
CollectionType.FemaleRaenNpc =>
"This collection applies to all female non-player character Raen Au Ra that do not have a more specific character collection associated.",
CollectionType.FemaleXaelaNpc =>
"This collection applies to all female non-player character Xaela Au Ra that do not have a more specific character collection associated.",
CollectionType.FemaleHelionNpc =>
"This collection applies to all female non-player character Helion Hrothgar that do not have a more specific character collection associated.",
CollectionType.FemaleLostNpc =>
"This collection applies to all female non-player character Lost Hrothgar that do not have a more specific character collection associated.",
CollectionType.FemaleRavaNpc =>
"This collection applies to all female non-player character Rava Viera that do not have a more specific character collection associated.",
CollectionType.FemaleVeenaNpc =>
"This collection applies to all female non-player character Veena Viera that do not have a more specific character collection associated.",
_ => string.Empty,
};
}

View file

@ -0,0 +1,173 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Penumbra.GameData.Actors;
using Penumbra.String;
namespace Penumbra.Collections.Manager;
public sealed partial class IndividualCollections : IReadOnlyList< (string DisplayName, ModCollection Collection) >
{
public IEnumerator< (string DisplayName, ModCollection Collection) > GetEnumerator()
=> _assignments.Select( t => ( t.DisplayName, t.Collection ) ).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public int Count
=> _assignments.Count;
public (string DisplayName, ModCollection Collection) this[ int index ]
=> ( _assignments[ index ].DisplayName, _assignments[ index ].Collection );
public bool TryGetCollection( ActorIdentifier identifier, [NotNullWhen( true )] out ModCollection? collection )
{
if( Count == 0 )
{
collection = null;
return false;
}
switch( identifier.Type )
{
case IdentifierType.Player: return CheckWorlds( identifier, out collection );
case IdentifierType.Retainer:
{
if( _individuals.TryGetValue( identifier, out collection ) )
{
return true;
}
if( identifier.Retainer is not ActorIdentifier.RetainerType.Mannequin && Penumbra.Config.UseOwnerNameForCharacterCollection )
{
return CheckWorlds( _actorManager.GetCurrentPlayer(), out collection );
}
break;
}
case IdentifierType.Owned:
{
if( CheckWorlds( identifier, out collection! ) )
{
return true;
}
// Handle generic NPC
var npcIdentifier = _actorManager.CreateIndividualUnchecked( IdentifierType.Npc, ByteString.Empty, ushort.MaxValue, identifier.Kind, identifier.DataId );
if( npcIdentifier.IsValid && _individuals.TryGetValue( npcIdentifier, out collection ) )
{
return true;
}
// Handle Ownership.
if( Penumbra.Config.UseOwnerNameForCharacterCollection )
{
identifier = _actorManager.CreateIndividualUnchecked( IdentifierType.Player, identifier.PlayerName, identifier.HomeWorld, ObjectKind.None, uint.MaxValue );
return CheckWorlds( identifier, out collection );
}
return false;
}
case IdentifierType.Npc: return _individuals.TryGetValue( identifier, out collection );
case IdentifierType.Special: return CheckWorlds( ConvertSpecialIdentifier( identifier ).Item1, out collection );
}
collection = null;
return false;
}
public enum SpecialResult
{
PartyBanner,
PvPBanner,
Mahjong,
CharacterScreen,
FittingRoom,
DyePreview,
Portrait,
Inspect,
Card,
Glamour,
Invalid,
}
public (ActorIdentifier, SpecialResult) ConvertSpecialIdentifier( ActorIdentifier identifier )
{
if( identifier.Type != IdentifierType.Special )
{
return ( identifier, SpecialResult.Invalid );
}
if( _actorManager.ResolvePartyBannerPlayer( identifier.Special, out var id ) )
{
return ( id, SpecialResult.PartyBanner );
}
if( _actorManager.ResolvePvPBannerPlayer( identifier.Special, out id ) )
{
return ( id, SpecialResult.PvPBanner );
}
if( _actorManager.ResolveMahjongPlayer( identifier.Special, out id ) )
{
return ( id, SpecialResult.Mahjong );
}
switch( identifier.Special )
{
case ScreenActor.CharacterScreen when Penumbra.Config.UseCharacterCollectionInMainWindow: return ( _actorManager.GetCurrentPlayer(), SpecialResult.CharacterScreen );
case ScreenActor.FittingRoom when Penumbra.Config.UseCharacterCollectionInTryOn: return ( _actorManager.GetCurrentPlayer(), SpecialResult.FittingRoom );
case ScreenActor.DyePreview when Penumbra.Config.UseCharacterCollectionInTryOn: return ( _actorManager.GetCurrentPlayer(), SpecialResult.DyePreview );
case ScreenActor.Portrait when Penumbra.Config.UseCharacterCollectionsInCards: return ( _actorManager.GetCurrentPlayer(), SpecialResult.Portrait );
case ScreenActor.ExamineScreen:
{
identifier = _actorManager.GetInspectPlayer();
if( identifier.IsValid )
{
return ( Penumbra.Config.UseCharacterCollectionInInspect ? identifier : ActorIdentifier.Invalid, SpecialResult.Inspect );
}
identifier = _actorManager.GetCardPlayer();
if( identifier.IsValid )
{
return ( Penumbra.Config.UseCharacterCollectionInInspect ? identifier : ActorIdentifier.Invalid, SpecialResult.Card );
}
return Penumbra.Config.UseCharacterCollectionInTryOn ? ( _actorManager.GetGlamourPlayer(), SpecialResult.Glamour ) : ( identifier, SpecialResult.Invalid );
}
default: return ( identifier, SpecialResult.Invalid );
}
}
public bool TryGetCollection( GameObject? gameObject, out ModCollection? collection )
=> TryGetCollection( _actorManager.FromObject( gameObject, true, false, false ), out collection );
public unsafe bool TryGetCollection( FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject* gameObject, out ModCollection? collection )
=> TryGetCollection( _actorManager.FromObject( gameObject, out _, true, false, false ), out collection );
private bool CheckWorlds( ActorIdentifier identifier, out ModCollection? collection )
{
if( !identifier.IsValid )
{
collection = null;
return false;
}
if( _individuals.TryGetValue( identifier, out collection ) )
{
return true;
}
identifier = _actorManager.CreateIndividualUnchecked( identifier.Type, identifier.PlayerName, ushort.MaxValue, identifier.Kind, identifier.DataId );
if( identifier.IsValid && _individuals.TryGetValue( identifier, out collection ) )
{
return true;
}
collection = null;
return false;
}
}

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json.Linq;
using Penumbra.Collections.Manager;
using Penumbra.GameData.Actors;
using Penumbra.String;
namespace Penumbra.Collections.Manager;
public partial class IndividualCollections
{
public JArray ToJObject()
{
var ret = new JArray();
foreach (var (name, identifiers, collection) in Assignments)
{
var tmp = identifiers[0].ToJson();
tmp.Add("Collection", collection.Name);
tmp.Add("Display", name);
ret.Add(tmp);
}
return ret;
}
public bool ReadJObject(JArray? obj, CollectionStorage storage)
{
if (obj == null)
return true;
var changes = false;
foreach (var data in obj)
{
try
{
var identifier = _actorManager.FromJson(data as JObject);
var group = GetGroup(identifier);
if (group.Length == 0 || group.Any(i => !i.IsValid))
{
changes = true;
Penumbra.ChatService.NotificationMessage("Could not load an unknown individual collection, removed.", "Load Failure",
NotificationType.Warning);
continue;
}
var collectionName = data["Collection"]?.ToObject<string>() ?? string.Empty;
if (collectionName.Length == 0 || !storage.ByName(collectionName, out var collection))
{
changes = true;
Penumbra.ChatService.NotificationMessage(
$"Could not load the collection \"{collectionName}\" as individual collection for {identifier}, set to None.",
"Load Failure",
NotificationType.Warning);
continue;
}
if (!Add(group, collection))
{
changes = true;
Penumbra.ChatService.NotificationMessage($"Could not add an individual collection for {identifier}, removed.",
"Load Failure",
NotificationType.Warning);
}
}
catch (Exception e)
{
changes = true;
Penumbra.ChatService.NotificationMessage($"Could not load an unknown individual collection, removed:\n{e}", "Load Failure",
NotificationType.Error);
}
}
return changes;
}
internal void Migrate0To1(Dictionary<string, ModCollection> old)
{
static bool FindDataId(string name, IReadOnlyDictionary<uint, string> data, out uint dataId)
{
var kvp = data.FirstOrDefault(kvp => kvp.Value.Equals(name, StringComparison.OrdinalIgnoreCase),
new KeyValuePair<uint, string>(uint.MaxValue, string.Empty));
dataId = kvp.Key;
return kvp.Value.Length > 0;
}
foreach (var (name, collection) in old)
{
var kind = ObjectKind.None;
var lowerName = name.ToLowerInvariant();
// Prefer matching NPC names, fewer false positives than preferring players.
if (FindDataId(lowerName, _actorManager.Data.Companions, out var dataId))
kind = ObjectKind.Companion;
else if (FindDataId(lowerName, _actorManager.Data.Mounts, out dataId))
kind = ObjectKind.MountType;
else if (FindDataId(lowerName, _actorManager.Data.BNpcs, out dataId))
kind = ObjectKind.BattleNpc;
else if (FindDataId(lowerName, _actorManager.Data.ENpcs, out dataId))
kind = ObjectKind.EventNpc;
var identifier = _actorManager.CreateNpc(kind, dataId);
if (identifier.IsValid)
{
// If the name corresponds to a valid npc, add it as a group. If this fails, notify users.
var group = GetGroup(identifier);
var ids = string.Join(", ", group.Select(i => i.DataId.ToString()));
if (Add($"{_actorManager.Data.ToName(kind, dataId)} ({kind.ToName()})", group, collection))
Penumbra.Log.Information($"Migrated {name} ({kind.ToName()}) to NPC Identifiers [{ids}].");
else
Penumbra.ChatService.NotificationMessage(
$"Could not migrate {name} ({collection.AnonymizedName}) which was assumed to be a {kind.ToName()} with IDs [{ids}], please look through your individual collections.",
"Migration Failure", NotificationType.Error);
}
// If it is not a valid NPC name, check if it can be a player name.
else if (ActorManager.VerifyPlayerName(name))
{
identifier = _actorManager.CreatePlayer(ByteString.FromStringUnsafe(name, false), ushort.MaxValue);
var shortName = string.Join(" ", name.Split().Select(n => $"{n[0]}."));
// Try to migrate the player name without logging full names.
if (Add($"{name} ({_actorManager.Data.ToWorldName(identifier.HomeWorld)})", new[]
{
identifier,
}, collection))
Penumbra.Log.Information($"Migrated {shortName} ({collection.AnonymizedName}) to Player Identifier.");
else
Penumbra.ChatService.NotificationMessage(
$"Could not migrate {shortName} ({collection.AnonymizedName}), please look through your individual collections.",
"Migration Failure", NotificationType.Error);
}
else
{
Penumbra.ChatService.NotificationMessage(
$"Could not migrate {name} ({collection.AnonymizedName}), which can not be a player name nor is it a known NPC name, please look through your individual collections.",
"Migration Failure", NotificationType.Error);
}
}
}
}

View file

@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Dalamud.Game.ClientState.Objects.Enums;
using OtterGui.Filesystem;
using Penumbra.GameData.Actors;
using Penumbra.Services;
using Penumbra.String;
namespace Penumbra.Collections.Manager;
public sealed partial class IndividualCollections
{
private readonly ActorManager _actorManager;
private readonly List<(string DisplayName, IReadOnlyList<ActorIdentifier> Identifiers, ModCollection Collection)> _assignments = new();
private readonly Dictionary<ActorIdentifier, ModCollection> _individuals = new();
public IReadOnlyList<(string DisplayName, IReadOnlyList<ActorIdentifier> Identifiers, ModCollection Collection)> Assignments
=> _assignments;
// TODO
public IndividualCollections(ActorService actorManager)
=> _actorManager = actorManager.AwaitedService;
public IndividualCollections(ActorManager actorManager)
=> _actorManager = actorManager;
public enum AddResult
{
Valid,
AlreadySet,
Invalid,
}
public bool TryGetValue(ActorIdentifier identifier, [NotNullWhen(true)] out ModCollection? collection)
{
lock (_individuals)
{
return _individuals.TryGetValue(identifier, out collection);
}
}
public bool ContainsKey(ActorIdentifier identifier)
{
lock (_individuals)
{
return _individuals.ContainsKey(identifier);
}
}
public AddResult CanAdd(params ActorIdentifier[] identifiers)
{
if (identifiers.Length == 0)
return AddResult.Invalid;
if (identifiers.Any(i => !i.IsValid))
return AddResult.Invalid;
bool set;
lock (_individuals)
{
set = identifiers.Any(_individuals.ContainsKey);
}
return set ? AddResult.AlreadySet : AddResult.Valid;
}
public AddResult CanAdd(IdentifierType type, string name, ushort homeWorld, ObjectKind kind, IEnumerable<uint> dataIds,
out ActorIdentifier[] identifiers)
{
identifiers = Array.Empty<ActorIdentifier>();
switch (type)
{
case IdentifierType.Player:
if (!ByteString.FromString(name, out var playerName))
return AddResult.Invalid;
identifiers = new[]
{
_actorManager.CreatePlayer(playerName, homeWorld),
};
break;
case IdentifierType.Retainer:
if (!ByteString.FromString(name, out var retainerName))
return AddResult.Invalid;
identifiers = new[]
{
_actorManager.CreateRetainer(retainerName, 0),
};
break;
case IdentifierType.Owned:
if (!ByteString.FromString(name, out var ownerName))
return AddResult.Invalid;
identifiers = dataIds.Select(id => _actorManager.CreateOwned(ownerName, homeWorld, kind, id)).ToArray();
break;
case IdentifierType.Npc:
identifiers = dataIds
.Select(id => _actorManager.CreateIndividual(IdentifierType.Npc, ByteString.Empty, ushort.MaxValue, kind, id)).ToArray();
break;
default:
identifiers = Array.Empty<ActorIdentifier>();
break;
}
return CanAdd(identifiers);
}
public ActorIdentifier[] GetGroup(ActorIdentifier identifier)
{
if (!identifier.IsValid)
return Array.Empty<ActorIdentifier>();
static ActorIdentifier[] CreateNpcs(ActorManager manager, ActorIdentifier identifier)
{
var name = manager.Data.ToName(identifier.Kind, identifier.DataId);
var table = identifier.Kind switch
{
ObjectKind.BattleNpc => manager.Data.BNpcs,
ObjectKind.EventNpc => manager.Data.ENpcs,
ObjectKind.Companion => manager.Data.Companions,
ObjectKind.MountType => manager.Data.Mounts,
(ObjectKind)15 => manager.Data.Ornaments,
_ => throw new NotImplementedException(),
};
return table.Where(kvp => kvp.Value == name)
.Select(kvp => manager.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, identifier.HomeWorld, identifier.Kind,
kvp.Key)).ToArray();
}
return identifier.Type switch
{
IdentifierType.Player => new[]
{
identifier.CreatePermanent(),
},
IdentifierType.Special => new[]
{
identifier,
},
IdentifierType.Retainer => new[]
{
identifier.CreatePermanent(),
},
IdentifierType.Owned => CreateNpcs(_actorManager, identifier.CreatePermanent()),
IdentifierType.Npc => CreateNpcs(_actorManager, identifier),
_ => Array.Empty<ActorIdentifier>(),
};
}
internal bool Add(ActorIdentifier[] identifiers, ModCollection collection)
{
if (identifiers.Length == 0 || !identifiers[0].IsValid)
return false;
var name = DisplayString(identifiers[0]);
return Add(name, identifiers, collection);
}
private bool Add(string displayName, ActorIdentifier[] identifiers, ModCollection collection)
{
if (CanAdd(identifiers) != AddResult.Valid
|| displayName.Length == 0
|| _assignments.Any(a => a.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase)))
return false;
for (var i = 0; i < identifiers.Length; ++i)
{
identifiers[i] = identifiers[i].CreatePermanent();
lock (_individuals)
{
_individuals.Add(identifiers[i], collection);
}
}
_assignments.Add((displayName, identifiers, collection));
return true;
}
internal bool ChangeCollection(ActorIdentifier identifier, ModCollection newCollection)
=> ChangeCollection(DisplayString(identifier), newCollection);
internal bool ChangeCollection(string displayName, ModCollection newCollection)
=> ChangeCollection(_assignments.FindIndex(t => t.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase)), newCollection);
internal bool ChangeCollection(int displayIndex, ModCollection newCollection)
{
if (displayIndex < 0 || displayIndex >= _assignments.Count || _assignments[displayIndex].Collection == newCollection)
return false;
_assignments[displayIndex] = _assignments[displayIndex] with { Collection = newCollection };
lock (_individuals)
{
foreach (var identifier in _assignments[displayIndex].Identifiers)
_individuals[identifier] = newCollection;
}
return true;
}
internal bool Delete(ActorIdentifier identifier)
=> Delete(Index(identifier));
internal bool Delete(string displayName)
=> Delete(Index(displayName));
internal bool Delete(int displayIndex)
{
if (displayIndex < 0 || displayIndex >= _assignments.Count)
return false;
var (name, identifiers, _) = _assignments[displayIndex];
_assignments.RemoveAt(displayIndex);
lock (_individuals)
{
foreach (var identifier in identifiers)
_individuals.Remove(identifier);
}
return true;
}
internal bool Move(int from, int to)
=> _assignments.Move(from, to);
internal int Index(string displayName)
=> _assignments.FindIndex(t => t.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase));
internal int Index(ActorIdentifier identifier)
=> identifier.IsValid ? Index(DisplayString(identifier)) : -1;
private string DisplayString(ActorIdentifier identifier)
{
return identifier.Type switch
{
IdentifierType.Player => $"{identifier.PlayerName} ({_actorManager.Data.ToWorldName(identifier.HomeWorld)})",
IdentifierType.Retainer => $"{identifier.PlayerName} (Retainer)",
IdentifierType.Owned =>
$"{identifier.PlayerName} ({_actorManager.Data.ToWorldName(identifier.HomeWorld)})'s {_actorManager.Data.ToName(identifier.Kind, identifier.DataId)}",
IdentifierType.Npc => $"{_actorManager.Data.ToName(identifier.Kind, identifier.DataId)} ({identifier.Kind.ToName()})",
_ => string.Empty,
};
}
}

View file

@ -0,0 +1,68 @@
using System;
using Dalamud.Interface.Internal.Notifications;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Collections.Manager;
public class InheritanceManager : IDisposable
{
private readonly CollectionStorage _storage;
private readonly CommunicatorService _communicator;
private readonly SaveService _saveService;
public InheritanceManager(CollectionStorage storage, SaveService saveService, CommunicatorService communicator)
{
_storage = storage;
_saveService = saveService;
_communicator = communicator;
ApplyInheritances();
_communicator.CollectionChange.Subscribe(OnCollectionChange);
}
public void Dispose()
{
_communicator.CollectionChange.Unsubscribe(OnCollectionChange);
}
/// <summary>
/// Inheritances can not be setup before all collections are read,
/// so this happens after reading the collections in the constructor, consuming the stored strings.
/// </summary>
private void ApplyInheritances()
{
foreach (var (collection, inheritances, changes) in _storage.ConsumeInheritanceNames())
{
var localChanges = changes;
foreach (var subCollection in inheritances)
{
if (collection.AddInheritance(subCollection, false))
continue;
localChanges = true;
Penumbra.ChatService.NotificationMessage($"{collection.Name} can not inherit from {subCollection.Name}, removed.", "Warning",
NotificationType.Warning);
}
if (localChanges)
_saveService.ImmediateSave(collection);
}
}
private void OnCollectionChange(CollectionType collectionType, ModCollection? old, ModCollection? newCollection, string _3)
{
if (collectionType is not CollectionType.Inactive || old == null)
return;
foreach (var inheritance in old.Inheritance)
old.ClearSubscriptions(inheritance);
foreach (var c in _storage)
{
var inheritedIdx = c._inheritance.IndexOf(old);
if (inheritedIdx >= 0)
c.RemoveInheritance(inheritedIdx);
}
}
}

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Penumbra.Api;
using Penumbra.GameData.Actors;
using Penumbra.Mods;
using Penumbra.Services;
using Penumbra.String;
namespace Penumbra.Collections.Manager;
public class TempCollectionManager : IDisposable
{
public int GlobalChangeCounter { get; private set; } = 0;
public readonly IndividualCollections Collections;
private readonly CommunicatorService _communicator;
private readonly CollectionStorage _storage;
private readonly Dictionary<string, ModCollection> _customCollections = new();
public TempCollectionManager(CommunicatorService communicator, ActorService actors, CollectionStorage storage)
{
_communicator = communicator;
_storage = storage;
Collections = new IndividualCollections(actors);
_communicator.TemporaryGlobalModChange.Subscribe(OnGlobalModChange);
}
public void Dispose()
{
_communicator.TemporaryGlobalModChange.Unsubscribe(OnGlobalModChange);
}
private void OnGlobalModChange(TemporaryMod mod, bool created, bool removed)
=> TempModManager.OnGlobalModChange(_customCollections.Values, mod, created, removed);
public int Count
=> _customCollections.Count;
public IEnumerable<ModCollection> Values
=> _customCollections.Values;
public bool CollectionByName(string name, [NotNullWhen(true)] out ModCollection? collection)
=> _customCollections.TryGetValue(name.ToLowerInvariant(), out collection);
public string CreateTemporaryCollection(string name)
{
if (_storage.ByName(name, out _))
return string.Empty;
if (GlobalChangeCounter == int.MaxValue)
GlobalChangeCounter = 0;
var collection = ModCollection.CreateNewTemporary(name, GlobalChangeCounter++);
if (_customCollections.TryAdd(collection.Name.ToLowerInvariant(), collection))
return collection.Name;
collection.ClearCache();
return string.Empty;
}
public bool RemoveTemporaryCollection(string collectionName)
{
if (!_customCollections.Remove(collectionName.ToLowerInvariant(), out var collection))
return false;
GlobalChangeCounter += Math.Max(collection.ChangeCounter + 1 - GlobalChangeCounter, 0);
collection.ClearCache();
for (var i = 0; i < Collections.Count; ++i)
{
if (Collections[i].Collection != collection)
continue;
_communicator.CollectionChange.Invoke(CollectionType.Temporary, collection, null, Collections[i].DisplayName);
Collections.Delete(i--);
}
return true;
}
public bool AddIdentifier(ModCollection collection, params ActorIdentifier[] identifiers)
{
if (Collections.Add(identifiers, collection))
{
_communicator.CollectionChange.Invoke(CollectionType.Temporary, null, collection, Collections.Last().DisplayName);
return true;
}
return false;
}
public bool AddIdentifier(string collectionName, params ActorIdentifier[] identifiers)
{
if (!_customCollections.TryGetValue(collectionName.ToLowerInvariant(), out var collection))
return false;
return AddIdentifier(collection, identifiers);
}
public bool AddIdentifier(string collectionName, string characterName, ushort worldId = ushort.MaxValue)
{
if (!ByteString.FromString(characterName, out var byteString, false))
return false;
var identifier = Penumbra.Actors.CreatePlayer(byteString, worldId);
if (!identifier.IsValid)
return false;
return AddIdentifier(collectionName, identifier);
}
internal bool RemoveByCharacterName(string characterName, ushort worldId = ushort.MaxValue)
{
if (!ByteString.FromString(characterName, out var byteString, false))
return false;
var identifier = Penumbra.Actors.CreatePlayer(byteString, worldId);
return Collections.TryGetValue(identifier, out var collection) && RemoveTemporaryCollection(collection.Name);
}
}