fix: Fixes missing destinations crashing (#1724)
This commit is contained in:
parent
6c07673a37
commit
d115716a60
2 changed files with 172 additions and 140 deletions
|
|
@ -2,133 +2,133 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential Tests")]
|
||||
public class TestStringHelpers
|
||||
{
|
||||
public class TestStringHelpers
|
||||
[Theory]
|
||||
[InlineData(null, "default value", "default value")]
|
||||
[InlineData("", "default value", "default value")]
|
||||
[InlineData("this is a valid string", "default value", "this is a valid string")]
|
||||
public void TestIsNullOrDefault(string value, string defaultValue, string expected)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, "default value", "default value")]
|
||||
[InlineData("", "default value", "default value")]
|
||||
[InlineData("this is a valid string", "default value", "this is a valid string")]
|
||||
public void TestIsNullOrDefault(string value, string defaultValue, string expected)
|
||||
{
|
||||
var actual = value.DefaultIfNullOrEmpty(defaultValue);
|
||||
var actual = value.DefaultIfNullOrEmpty(defaultValue);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("this is not capitalized", "This Is Not Capitalized")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("nospaceshere", "Nospaceshere")]
|
||||
[InlineData("harry the fireman", "Harry the Fireman")]
|
||||
public void TestCapitalize(string original, string capitalized)
|
||||
{
|
||||
var actual = original.Capitalize();
|
||||
|
||||
Assert.Equal(capitalized, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("we are testing removing spaces", " ", "wearetestingremovingspaces", StringComparison.Ordinal)]
|
||||
[InlineData("", " ", "", StringComparison.Ordinal)]
|
||||
[InlineData(null, null, null, StringComparison.Ordinal)]
|
||||
public void TestRemove(string original, string separator, string removed, StringComparison comparison)
|
||||
{
|
||||
var actual = original.AsSpan().Remove(separator, comparison);
|
||||
|
||||
Assert.Equal(removed, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("this is a sentence that will probably wrap around a few times because it is long", 10, 6)]
|
||||
[InlineData("An Unnamed House", 10, 6)]
|
||||
[InlineData("Batville", 10, 6)]
|
||||
[InlineData("Bald's Shop", 10, 6)]
|
||||
[InlineData(
|
||||
"Something ThatIsVeryLongAndShouldBe broken up",
|
||||
10, 6,
|
||||
"Something", "ThatIsVery", "LongAndSho", "uldBe", "broken up"
|
||||
)]
|
||||
public void TestWrap(string sentence, int perLine, int maxLines, params string[] customExpected)
|
||||
{
|
||||
var expected = customExpected.Length > 0
|
||||
? customExpected
|
||||
: OldWrap(sentence, perLine, maxLines).ToArray();
|
||||
var actual = sentence.Wrap(perLine, maxLines).ToArray();
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// The old wrap function from HouseGump/HouseGumpAOS
|
||||
private static List<string> OldWrap(string value, int startIndex, int maxLines)
|
||||
{
|
||||
if (value == null || (value = value.Trim()).Length <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("this is not capitalized", "This Is Not Capitalized")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("nospaceshere", "Nospaceshere")]
|
||||
[InlineData("harry the fireman", "Harry the Fireman")]
|
||||
public void TestCapitalize(string original, string capitalized)
|
||||
var values = value.Split(' ');
|
||||
var list = new List<string>();
|
||||
var current = "";
|
||||
|
||||
for (var i = 0; i < values.Length; ++i)
|
||||
{
|
||||
var actual = original.Capitalize();
|
||||
var val = values[i];
|
||||
|
||||
Assert.Equal(capitalized, actual);
|
||||
}
|
||||
var v = current.Length == 0 ? val : $"{current} {val}";
|
||||
|
||||
[Theory]
|
||||
[InlineData("we are testing removing spaces", " ", "wearetestingremovingspaces", StringComparison.Ordinal)]
|
||||
[InlineData("", " ", "", StringComparison.Ordinal)]
|
||||
[InlineData(null, null, null, StringComparison.Ordinal)]
|
||||
public void TestRemove(string original, string separator, string removed, StringComparison comparison)
|
||||
{
|
||||
var actual = original.AsSpan().Remove(separator, comparison);
|
||||
|
||||
Assert.Equal(removed, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("this is a sentence that will probably wrap around a few times because it is long", 10, 6)]
|
||||
[InlineData("An Unnamed House", 10, 6)]
|
||||
[InlineData("Batville", 10, 6)]
|
||||
[InlineData("Bald's Shop", 10, 6)]
|
||||
[InlineData(
|
||||
"Something ThatIsVeryLongAndShouldBe broken up",
|
||||
10, 6,
|
||||
"Something", "ThatIsVery", "LongAndSho", "uldBe", "broken up"
|
||||
)]
|
||||
public void TestWrap(string sentence, int perLine, int maxLines, params string[] customExpected)
|
||||
{
|
||||
var expected = customExpected.Length > 0
|
||||
? customExpected
|
||||
: OldWrap(sentence, perLine, maxLines).ToArray();
|
||||
var actual = sentence.Wrap(perLine, maxLines).ToArray();
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// The old wrap function from HouseGump/HouseGumpAOS
|
||||
private static List<string> OldWrap(string value, int startIndex, int maxLines)
|
||||
{
|
||||
if (value == null || (value = value.Trim()).Length <= 0)
|
||||
if (v.Length < startIndex)
|
||||
{
|
||||
return null;
|
||||
current = v;
|
||||
}
|
||||
|
||||
var values = value.Split(' ');
|
||||
var list = new List<string>();
|
||||
var current = "";
|
||||
|
||||
for (var i = 0; i < values.Length; ++i)
|
||||
else if (v.Length == startIndex)
|
||||
{
|
||||
var val = values[i];
|
||||
list.Add(v);
|
||||
|
||||
var v = current.Length == 0 ? val : $"{current} {val}";
|
||||
|
||||
if (v.Length < startIndex)
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
current = v;
|
||||
return list;
|
||||
}
|
||||
else if (v.Length == startIndex)
|
||||
{
|
||||
list.Add(v);
|
||||
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
current = "";
|
||||
}
|
||||
else if (val.Length <= startIndex)
|
||||
{
|
||||
list.Add(current);
|
||||
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
current = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
while (v.Length >= startIndex)
|
||||
{
|
||||
list.Add(v[..startIndex]);
|
||||
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
v = v[startIndex..];
|
||||
}
|
||||
|
||||
current = v;
|
||||
}
|
||||
current = "";
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
else if (val.Length <= startIndex)
|
||||
{
|
||||
list.Add(current);
|
||||
}
|
||||
|
||||
return list;
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
current = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
while (v.Length >= startIndex)
|
||||
{
|
||||
list.Add(v[..startIndex]);
|
||||
|
||||
if (list.Count == maxLines)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
v = v[startIndex..];
|
||||
}
|
||||
|
||||
current = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
list.Add(current);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,26 +30,28 @@ public partial class BaseEscortable : BaseCreature
|
|||
public static readonly TimeSpan DeleteTime =
|
||||
MLQuestSystem.Enabled ? TimeSpan.FromSeconds(100) : TimeSpan.FromSeconds(30);
|
||||
|
||||
public static bool Initialized { get; set; }
|
||||
|
||||
// Classic list
|
||||
// Used when: !MLQuestSystem.Enabled && !Core.ML
|
||||
private static readonly string[] _townNames =
|
||||
{
|
||||
public static readonly string[] TownNames =
|
||||
[
|
||||
"Cove", "Britain", "Jhelom",
|
||||
"Minoc", "Ocllo", "Trinsic",
|
||||
"Vesper", "Yew", "Skara Brae",
|
||||
"Nujel'm", "Moonglow", "Magincia"
|
||||
};
|
||||
];
|
||||
|
||||
// ML list, pre-ML quest system
|
||||
// Used when: !MLQuestSystem.Enabled && Core.ML
|
||||
private static readonly string[] _mlTownNames =
|
||||
{
|
||||
public static readonly string[] MlTownNames =
|
||||
[
|
||||
"Cove", "Serpent's Hold", "Jhelom", "Nujel'm"
|
||||
};
|
||||
];
|
||||
|
||||
// ML quest system general list
|
||||
// Used when: MLQuestSystem.Enabled && !Region.IsPartOf( "Haven Island" )
|
||||
private static readonly Dictionary<Type, (int[], int)> m_MLQuestTypes = new()
|
||||
private static readonly Dictionary<Type, (int[], int)> _mlQuestTypes = new()
|
||||
{
|
||||
{ typeof(EscortToYew), (Array.Empty<int>(), 0) },
|
||||
{ typeof(EscortToVesper), (Array.Empty<int>(), 0) },
|
||||
|
|
@ -71,25 +73,25 @@ public partial class BaseEscortable : BaseCreature
|
|||
// Used when: MLQuestSystem.Enabled && Region.IsPartOf("Haven Island")
|
||||
// TODO: Find out if these specific quest messages are for special one-off quest characters
|
||||
// that teach you where things are in a city instead of the randomly spawned ones.
|
||||
private static readonly Dictionary<Type, (int[], int)> m_MLQuestTypesNH = new()
|
||||
private static readonly Dictionary<Type, (int[], int)> _mlQuestTypesNH = new()
|
||||
{
|
||||
// I am missing several components for my new potions, and I need to find the local alchemist.
|
||||
// My daughter is sick , and I need medicine. Do you know the way to the local alchemist?
|
||||
// I need some potions before I set out for a long journey. Can you take me to the alchemist in The Bottled Imp?
|
||||
// I’m looking to go to the Alchemist's shop. Will you take me?
|
||||
{ typeof(EscortToNHAlchemist), (new[] { 1042767, 1042768, 1042769, 1042824 }, 1042811) },
|
||||
{ typeof(EscortToNHAlchemist), ([1042767, 1042768, 1042769, 1042824], 1042811) },
|
||||
|
||||
// I need new string for my lute, yet I do not know the way to the local music shop, could you take me?
|
||||
// I was hoping to hire a bard for my birthday party. Can you take me to one?
|
||||
// I fear my talent for music is less than my desire to learn, yet still I would like to try. Can you take me to the local music shop?
|
||||
// I’m looking to go to the music center. Will you take me?
|
||||
{ typeof(EscortToNHBard), (new [] { 1042770, 1042771, 1042772, 1042825 }, 1042812) },
|
||||
{ typeof(EscortToNHBard), ([1042770, 1042771, 1042772, 1042825], 1042812) },
|
||||
|
||||
// A family heirloom, our armoire, is falling apart. I need to see the local carpenter. Would you guide me to her?
|
||||
// My goat has broken through our fence, and I need new boards. Can you direct me to the local wood worker?
|
||||
// I need a hammer and nails. Never mind for what. Take me to the local carpenter or leave me be.
|
||||
// I’m looking to go to the local woodworker. Will you take me?
|
||||
{ typeof(EscortToNHCarpenter), (new [] { 1042773, 1042774, 1042775, 1042829 }, 1042816) },
|
||||
{ typeof(EscortToNHCarpenter), ([1042773, 1042774, 1042775, 1042829], 1042816) },
|
||||
|
||||
//TODO: Add woodsman (camping, tracker, etc)
|
||||
// 1042776 - I have a job for the local woodsman. I have lost my dog. Can you take me to see him?
|
||||
|
|
@ -102,37 +104,37 @@ public partial class BaseEscortable : BaseCreature
|
|||
// I want to learn how to sew. Can you take me to see the tailor?
|
||||
// I need new clothes for a party, and I was wondering if you could take me to the tailor?
|
||||
// I’m looking to go to the local tailor. Will you take me?
|
||||
{ typeof(EscortToNHTailor), (new [] { 1042779, 1042780, 1042781, 1042828, }, 1042815) },
|
||||
{ typeof(EscortToNHTailor), ([1042779, 1042780, 1042781, 1042828], 1042815) },
|
||||
|
||||
// I need to deposit some gold. You look like a trustworthy soul, so could you direct me to the local bank?
|
||||
// A rich relative of mine said they deposited some gold in to my account. Would you be able to lead me to the bank?
|
||||
// I have a debt I need to pay off at the bank. Do you know the way there?
|
||||
// I’m looking to go to the city bank. Will you take me?
|
||||
{ typeof(EscortToNHBank), (new [] { 1042782, 1042783, 1042784, 1042832 }, 1042819) },
|
||||
{ typeof(EscortToNHBank), ([1042782, 1042783, 1042784, 1042832], 1042819) },
|
||||
|
||||
// I wish to travel and see the world, but I fear I need martial skills. Would you direct me to the local weapons trainer?
|
||||
// I need a sword to accompany me on a journey. Would escort me to the local fighter's union?
|
||||
// I need someone to help me rid my home of mongbats. Please take me to the local swordfighter.
|
||||
// I’m looking to go to the weapon trainer's. Will you take me?
|
||||
{ typeof(EscortToNHWarrior), (new [] { 1042785, 1042786, 1042787, 1042827 }, 1042814) },
|
||||
{ typeof(EscortToNHWarrior), ([1042785, 1042786, 1042787, 1042827], 1042814) },
|
||||
|
||||
// My new house requires blessing, but I am not a mage and I have not the scroll. Would you take me to the local mages guild?
|
||||
// I need a wizard. I can't say why. You'll take me to one, or won't you?
|
||||
// You there. Take me to see a sorcerer so I can turn a friend back in to a human. He is currently a cat and keeps demanding milk.
|
||||
// I’m looking to go to the magic shop. Will you take me?
|
||||
{ typeof(EscortToNHMage), (new [] { 1042788, 1042789, 1042790, 1042833 }, 1042820) },
|
||||
{ typeof(EscortToNHMage), ([1042788, 1042789, 1042790, 1042833], 1042820) },
|
||||
|
||||
// Psst - I hate to admit it, but I am lost. Can you take me to a place where they sell maps?
|
||||
// I am trying to confirm the location of dungeons around here. Would you take me to a map maker so I might buy supplies?
|
||||
// Where am I? Who am I? Do you know me? Hmmm - on second thought, I think I best stick with where I am first. Do you know where I can get a map?
|
||||
// I’m looking to go to the local Map maker's. Will you take me?
|
||||
{ typeof(EscortToNHMapmaker), (new [] { 1042791, 1042792, 1042793, 1042835 }, 1042822) },
|
||||
{ typeof(EscortToNHMapmaker), ([1042791, 1042792, 1042793, 1042835], 1042822) },
|
||||
|
||||
// I am in search of a loaf of fresh bread. Do you know where I might find some?
|
||||
// I need to find some spices for my stew. The local chef might have some for me, can you take me to see him?
|
||||
// I need something to eat. I am starving. Can you take me to the inn?
|
||||
// I’m looking to go to the New Haven Inn. Will you take me?
|
||||
{ typeof(EscortToNHInn), (new [] { 1042794, 1042795, 1042796, 1042834 }, 1042821) },
|
||||
{ typeof(EscortToNHInn), ([1042794, 1042795, 1042796, 1042834], 1042821) },
|
||||
|
||||
// Hey you! I need to find a farmer because all my plants keep dying. Please take me to one.
|
||||
// Do you know where I might find a person who sells seeds for crops?
|
||||
|
|
@ -144,13 +146,13 @@ public partial class BaseEscortable : BaseCreature
|
|||
// You know, I would really like some fish, but I do not know where a fisherman is. Do you?
|
||||
// I have heard of a magical fish that grants wishes. I bet THAT fisherman knows where the fish is. Please take me to him.
|
||||
// I’m looking to go to the fishing wharf. Will you take me?
|
||||
{ typeof(EscortToNHDocks), (new [] { 1042800, 1042801, 1042802, 1042826 }, 1042813) },
|
||||
{ typeof(EscortToNHDocks), ([1042800, 1042801, 1042802, 1042826], 1042813) },
|
||||
|
||||
// I need arrows to hunt rabbits for me rabbit stew. Can you take me to the local archer?
|
||||
// I have a huge amount of feathers for the local Fletcher. Where might I find him?
|
||||
// You there. Do you know the way to the local archer?
|
||||
// I’m looking to go to the local archery range. Will you take me?
|
||||
{ typeof(EscortToNHBowyer), (new [] { 1042803, 1042804, 1042805, 1042831 }, 1042818) },
|
||||
{ typeof(EscortToNHBowyer), ([1042803, 1042804, 1042805, 1042831], 1042818) },
|
||||
};
|
||||
|
||||
[SerializableField(0, setter: "private")]
|
||||
|
|
@ -226,7 +228,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
if (_mlQuestType == null)
|
||||
{
|
||||
var reg = Region;
|
||||
var types = reg.IsPartOf("Haven Island") ? m_MLQuestTypesNH : m_MLQuestTypes;
|
||||
var types = reg.IsPartOf("Haven Island") ? _mlQuestTypesNH : _mlQuestTypes;
|
||||
|
||||
// Get a rented buffer
|
||||
var list = STArrayPool<Type>.Shared.Rent(types.Keys.Count);
|
||||
|
|
@ -267,7 +269,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
_mlQuestDestinationMessage = destinationMessage?.Length > 0 ? destinationMessage.RandomElement() : 0;
|
||||
_mlQuestPaymentMessage = paymentMessage;
|
||||
// Cached by BaseCreature in m_MLQuests
|
||||
_mlQuest = new List<MLQuest>(1) { quest };
|
||||
_mlQuest = [quest];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -709,10 +711,10 @@ public partial class BaseEscortable : BaseCreature
|
|||
if (MLQuestSystem.Enabled && quest != null && !StaticMLQuester)
|
||||
{
|
||||
_mlQuestType = quest.GetType();
|
||||
_mlQuest = new List<MLQuest>(1) { quest };
|
||||
_mlQuest = [quest];
|
||||
|
||||
// This isn't serialized before codegen
|
||||
if (m_MLQuestTypesNH.TryGetValue(_mlQuestType, out var tuple))
|
||||
if (_mlQuestTypesNH.TryGetValue(_mlQuestType, out var tuple))
|
||||
{
|
||||
var (destinationMessages, paymentMessage) = tuple;
|
||||
_mlQuestDestinationMessage = destinationMessages?.Length > 0 ? destinationMessages.RandomElement() : 0;
|
||||
|
|
@ -729,7 +731,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
var quest = MLQuestSystem.FindQuest(_mlQuestType);
|
||||
if (quest != null)
|
||||
{
|
||||
_mlQuest = new List<MLQuest>(1) { quest };
|
||||
_mlQuest = [quest];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -764,7 +766,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
base.AddCustomContextEntries(from, list);
|
||||
}
|
||||
|
||||
public virtual string[] GetPossibleDestinations() => Core.ML ? _mlTownNames : _townNames;
|
||||
public virtual string[] GetPossibleDestinations() => Core.ML ? MlTownNames : TownNames;
|
||||
|
||||
public virtual string PickRandomDestination()
|
||||
{
|
||||
|
|
@ -781,7 +783,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
picked = possible.RandomElement();
|
||||
var test = EDI.Find(picked);
|
||||
|
||||
if (test.Contains(Location))
|
||||
if (test?.Contains(Location) != false)
|
||||
{
|
||||
picked = null;
|
||||
}
|
||||
|
|
@ -792,7 +794,7 @@ public partial class BaseEscortable : BaseCreature
|
|||
|
||||
public EDI GetDestination()
|
||||
{
|
||||
if (MLQuestSystem.Enabled)
|
||||
if (MLQuestSystem.Enabled || !Initialized)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
|
@ -809,16 +811,26 @@ public partial class BaseEscortable : BaseCreature
|
|||
|
||||
if (Map.Felucca.Regions.Count > 0)
|
||||
{
|
||||
return _destination = EDI.Find(_destinationString);
|
||||
_destination = EDI.Find(_destinationString);
|
||||
|
||||
// The destination string used to exist, but doesn't anymore.
|
||||
if (_destination == null)
|
||||
{
|
||||
_destinationString = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Destination is invalid, so set it to null and return.
|
||||
return _destination = null;
|
||||
}
|
||||
}
|
||||
|
||||
public class EscortDestinationInfo
|
||||
{
|
||||
private static Dictionary<string, EscortDestinationInfo> _table;
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(EscortDestinationInfo));
|
||||
|
||||
private static Dictionary<string, EDI> _table;
|
||||
|
||||
public EscortDestinationInfo(string name, Region region)
|
||||
{
|
||||
|
|
@ -841,15 +853,35 @@ public class EscortDestinationInfo
|
|||
return;
|
||||
}
|
||||
|
||||
_table = new Dictionary<string, EscortDestinationInfo>();
|
||||
_table = new Dictionary<string, EDI>();
|
||||
|
||||
foreach (Region r in list)
|
||||
{
|
||||
if (r.Name != null && r is DungeonRegion or TownRegion)
|
||||
if (r is DungeonRegion or TownRegion && r.Name != null)
|
||||
{
|
||||
_table[r.Name] = new EscortDestinationInfo(r.Name, r);
|
||||
_table[r.Name] = new EDI(r.Name, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that we have at least one valid destination
|
||||
var validTown = false;
|
||||
var townNamesVariable = Core.ML ? nameof(BaseEscortable.MlTownNames) : nameof(BaseEscortable.TownNames);
|
||||
var towns = Core.ML ? BaseEscortable.MlTownNames : BaseEscortable.TownNames;
|
||||
for (var i = 0; i < towns.Length; ++i)
|
||||
{
|
||||
var town = towns[i];
|
||||
if (_table.ContainsKey(town))
|
||||
{
|
||||
validTown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validTown)
|
||||
{
|
||||
BaseEscortable.Initialized = false;
|
||||
logger.Error( "No valid escort destinations found. Please check {TownNames}.", townNamesVariable);
|
||||
}
|
||||
}
|
||||
|
||||
public static EDI Find(string name)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue