diff --git a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs
index b7396ace2..ce34fea8f 100644
--- a/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs
+++ b/src/NosCore.Data/Enumerations/I18N/LanguageKey.cs
@@ -198,6 +198,8 @@ public enum LanguageKey
OBJECT_IN_BAZAAR,
ITEM_GIFTED,
NOT_IN_FRIENDLIST,
- SPEAKER
+ SPEAKER,
+ TIMESPACE_LEVEL_NOT_ALLOWED,
+ TIMESPACE_NOT_AVAILABLE
}
}
diff --git a/src/NosCore.Data/Resource/LocalizedResources.cs.resx b/src/NosCore.Data/Resource/LocalizedResources.cs.resx
index 29d4ffac8..0087eea35 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.cs.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.cs.resx
@@ -557,4 +557,10 @@
Načteno skriptovaných instancí: {0}!
+
+ Tvá úroveň ti neumožňuje vstoupit do tohoto time-space.
+
+
+ Tento time-space zatím není dostupný.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.de.resx b/src/NosCore.Data/Resource/LocalizedResources.de.resx
index 4aaf486bb..859d721d0 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.de.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.de.resx
@@ -580,4 +580,10 @@
{0} Skript-Instanzen geladen!
+
+ Dein Level erlaubt dir nicht, diesen Time-Space zu betreten.
+
+
+ Dieser Time-Space ist noch nicht verfügbar.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.es.resx b/src/NosCore.Data/Resource/LocalizedResources.es.resx
index 7f9e36fbe..f0defb66e 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.es.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.es.resx
@@ -484,4 +484,10 @@
¡{0} instancias de script analizadas!
+
+ Tu nivel no te permite entrar en este time-space.
+
+
+ Este time-space aún no está disponible.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.fr.resx b/src/NosCore.Data/Resource/LocalizedResources.fr.resx
index a8545c5a2..9bee7f378 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.fr.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.fr.resx
@@ -555,4 +555,10 @@
{0} instances scriptées analysées !
+
+ Votre niveau ne vous permet pas d'entrer dans ce time-space.
+
+
+ Ce time-space n'est pas encore disponible.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.it.resx b/src/NosCore.Data/Resource/LocalizedResources.it.resx
index fbc84dfc6..6c06546ae 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.it.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.it.resx
@@ -601,4 +601,10 @@
{0} istanze scriptate caricate!
+
+ Il tuo livello non ti permette di entrare in questo time-space.
+
+
+ Questo time-space non è ancora disponibile.
+
diff --git a/src/NosCore.Data/Resource/LocalizedResources.pl.resx b/src/NosCore.Data/Resource/LocalizedResources.pl.resx
index 726fe7997..cdda954af 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.pl.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.pl.resx
@@ -507,4 +507,10 @@
Wczytano {0} instancji skryptowanych!
+
+ Twój poziom nie pozwala ci wejść do tego time-space'a.
+
+
+ Ten time-space nie jest jeszcze dostępny.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.resx b/src/NosCore.Data/Resource/LocalizedResources.resx
index 205567ea6..70ffd183f 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.resx
@@ -631,4 +631,10 @@
{0} Scripted Instances Parsed!
+
+ Your level does not allow you to enter this time-space.
+
+
+ This time-space is not available yet.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.ru.resx b/src/NosCore.Data/Resource/LocalizedResources.ru.resx
index 738eef0ec..2b25b77dd 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.ru.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.ru.resx
@@ -582,4 +582,10 @@
Загружено скриптовых инстансов: {0}!
+
+ Ваш уровень не позволяет войти в этот тайм-спейс.
+
+
+ Этот тайм-спейс пока недоступен.
+
\ No newline at end of file
diff --git a/src/NosCore.Data/Resource/LocalizedResources.tr.resx b/src/NosCore.Data/Resource/LocalizedResources.tr.resx
index 8b4809386..6528e75e9 100644
--- a/src/NosCore.Data/Resource/LocalizedResources.tr.resx
+++ b/src/NosCore.Data/Resource/LocalizedResources.tr.resx
@@ -573,4 +573,10 @@
{0} betikli örnek yüklendi!
+
+ Seviyeniz bu time-space'e girmenize izin vermiyor.
+
+
+ Bu time-space henüz kullanılamıyor.
+
\ No newline at end of file
diff --git a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs
index ce31b1045..e240a38c7 100644
--- a/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs
+++ b/src/NosCore.GameObject/Services/MapChangeService/MapChangeService.cs
@@ -36,7 +36,8 @@ public class MapChangeService(IExperienceService experienceService, IJobExperien
IClock clock,
ILogLanguageLocalizer logLanguage, IMinilandService minilandProvider, ILogger logger,
ILogLanguageLocalizer logLanguageLocalizer, IGameLanguageLocalizer gameLanguageLocalizer,
- ISessionRegistry sessionRegistry, Wolverine.IMessageBus messageBus)
+ ISessionRegistry sessionRegistry, Wolverine.IMessageBus messageBus,
+ ScriptedInstanceService.IScriptedInstanceService scriptedInstanceService)
: IMapChangeService
{
public async Task ChangeMapAsync(ClientSession session, short? mapId = null, short? mapX = null, short? mapY = null)
@@ -138,6 +139,8 @@ public async Task ChangeMapInstanceAsync(ClientSession session, Guid mapInstance
currentMapInstance.IsSleeping = true;
}
+ var abandonedRun = scriptedInstanceService.GetRun(currentMapInstance.MapInstanceId);
+
var playerEntity = newMapInstance.EcsWorld.ClonePlayer(
identity,
health,
@@ -172,6 +175,11 @@ position with
session.SetPlayerEntity(playerEntity, newMapInstance.EcsWorld);
character = session.Character;
+ if (abandonedRun != null)
+ {
+ await scriptedInstanceService.DisposeIfEmptyAsync(abandonedRun);
+ }
+
character.Group.LeaveGroup(character);
character.Group.JoinGroup(character);
@@ -196,6 +204,8 @@ position with
await session.SendPacketsAsync(newMapInstance.GetMapItems(accountLanguage));
await session.SendPacketsAsync(newMapInstance.MapDesignObjects.Values.Select(mp => mp.GenerateEffect()));
+ await session.SendPacketsAsync(scriptedInstanceService.GenerateWp(newMapInstance.Map.MapId));
+
var minilandPortals = minilandProvider
.GetMinilandPortals(characterId)
.Where(s => s.SourceMapInstanceId == mapInstanceId)
diff --git a/src/NosCore.GameObject/Services/MapInstanceGenerationService/MapInstance.cs b/src/NosCore.GameObject/Services/MapInstanceGenerationService/MapInstance.cs
index d3b01f179..86cb93dc2 100644
--- a/src/NosCore.GameObject/Services/MapInstanceGenerationService/MapInstance.cs
+++ b/src/NosCore.GameObject/Services/MapInstanceGenerationService/MapInstance.cs
@@ -37,7 +37,6 @@
using System.Reactive.Subjects;
using System.Threading.Tasks;
-
namespace NosCore.GameObject.Services.MapInstanceGenerationService
{
public class MapInstance : IBroadcastable, IDisposable
@@ -177,6 +176,10 @@ public bool IsSleeping
public MapInstanceType MapInstanceType { get; set; }
+ public byte MapIndexX { get; set; }
+
+ public byte MapIndexY { get; set; }
+
public List Monsters
{
get { return _monsters.Select(s => s.Value).ToList(); }
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/IScriptedInstanceService.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/IScriptedInstanceService.cs
new file mode 100644
index 000000000..0efb76325
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/IScriptedInstanceService.cs
@@ -0,0 +1,28 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using NosCore.Packets.ServerPackets.MiniMap;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public interface IScriptedInstanceService
+ {
+ IReadOnlyList GetByMap(short mapId);
+
+ ScriptedInstance? GetAt(short mapId, short positionX, short positionY);
+
+ IEnumerable GenerateWp(short mapId);
+
+ Task InstantiateAsync(ScriptedInstance entrance);
+
+ ScriptedInstanceRun? GetRun(Guid mapInstanceId);
+
+ Task DisposeIfEmptyAsync(ScriptedInstanceRun run);
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/InstanceDefinitionBuilder.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/InstanceDefinitionBuilder.cs
new file mode 100644
index 000000000..08861e227
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/InstanceDefinitionBuilder.cs
@@ -0,0 +1,118 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using System.Collections.Generic;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public sealed class InstanceDefinitionBuilder
+ {
+ private readonly List _rooms = [];
+ private readonly List _required = [];
+ private readonly List _draw = [];
+ private readonly List _special = [];
+ private readonly List _gift = [];
+
+ private byte _id;
+ private string? _label;
+ private string? _title;
+ private byte _levelMinimum;
+ private byte _levelMaximum;
+ private byte _lives;
+ private short _startX;
+ private short _startY;
+ private long _gold;
+ private int _reputation;
+ private int _familyExperience;
+
+ public static InstanceDefinitionBuilder Named(byte id, string label, string title)
+ {
+ return new InstanceDefinitionBuilder { _id = id, _label = label, _title = title };
+ }
+
+ public InstanceDefinitionBuilder ForLevels(byte minimum, byte maximum)
+ {
+ _levelMinimum = minimum;
+ _levelMaximum = maximum;
+ return this;
+ }
+
+ public InstanceDefinitionBuilder WithLives(byte lives)
+ {
+ _lives = lives;
+ return this;
+ }
+
+ public InstanceDefinitionBuilder StartingAt(short x, short y)
+ {
+ _startX = x;
+ _startY = y;
+ return this;
+ }
+
+ public InstanceDefinitionBuilder Rewarding(long gold = 0, int reputation = 0, int familyExperience = 0)
+ {
+ _gold = gold;
+ _reputation = reputation;
+ _familyExperience = familyExperience;
+ return this;
+ }
+
+ public InstanceDefinitionBuilder WithRoom(short mapVNum, out int key, byte indexX = 0, byte indexY = 0)
+ {
+ key = _rooms.Count + 1;
+ _rooms.Add(new InstanceRoom(key, mapVNum, indexX, indexY));
+ return this;
+ }
+
+ public InstanceDefinitionBuilder Requiring(short vNum, short amount)
+ {
+ _required.Add(new InstanceGift(vNum, amount));
+ return this;
+ }
+
+ public InstanceDefinitionBuilder Drawing(short vNum, short amount, short design = 0, bool randomRare = false)
+ {
+ _draw.Add(new InstanceGift(vNum, amount, design, randomRare));
+ return this;
+ }
+
+ public InstanceDefinitionBuilder WithSpecialReward(short vNum, short amount, bool heroic = false)
+ {
+ _special.Add(new InstanceGift(vNum, amount, 0, false, heroic));
+ return this;
+ }
+
+ public InstanceDefinitionBuilder WithReward(short vNum, short amount)
+ {
+ _gift.Add(new InstanceGift(vNum, amount));
+ return this;
+ }
+
+ public ScriptedInstanceDefinition Build()
+ {
+ return new ScriptedInstanceDefinition
+ {
+ Id = _id,
+ Label = _label,
+ Title = _title,
+ LevelMinimum = _levelMinimum,
+ LevelMaximum = _levelMaximum,
+ Lives = _lives,
+ StartX = _startX,
+ StartY = _startY,
+ Gold = _gold,
+ Reputation = _reputation,
+ FamilyExperience = _familyExperience,
+ RequiredItems = _required,
+ DrawItems = _draw,
+ SpecialItems = _special,
+ GiftItems = _gift,
+ Rooms = _rooms
+ };
+ }
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstance.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstance.cs
new file mode 100644
index 000000000..453d37504
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstance.cs
@@ -0,0 +1,79 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using NosCore.Data.StaticEntities;
+using NosCore.Packets.Enumerations;
+using NosCore.Packets.ServerPackets.Event;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public class ScriptedInstance : ScriptedInstanceDto
+ {
+ public ScriptedInstanceDefinition? Definition { get; set; }
+
+ public byte EffectiveLevelMinimum =>
+ Definition is { LevelMinimum: > 0 } ? Definition.LevelMinimum : LevelMinimum;
+
+ public byte EffectiveLevelMaximum
+ {
+ get
+ {
+ var ceiling = Definition is { LevelMaximum: > 0 } ? Definition.LevelMaximum : LevelMaximum;
+ return ceiling == 0 ? byte.MaxValue : ceiling;
+ }
+ }
+
+ public RbrPacket GenerateRbr()
+ {
+ return new RbrPacket
+ {
+ TsBasicInfo = new RbrSubPacketBasicInfo
+ {
+ TsId = Definition?.Id ?? 0,
+ TsType = IsHeroic ? RbrPacketTsType.HeroMission : RbrPacketTsType.MainMission,
+ TsConditionType = RbrPacketTsConditionType.CanEnterAlone
+ },
+ Unknown = 0,
+ Completed = false,
+ MinMaxLevel = new RbrSubPacketMinMaxLevel
+ {
+ MinLevel = EffectiveLevelMinimum,
+ MaxLevel = EffectiveLevelMaximum
+ },
+ RequiredSeeds = (short)(Definition?.RequiredItems.Sum(s => s.Amount) ?? 0),
+ DrawRewards = Slots(Definition?.DrawItems, 5),
+ SpecialRewards = Slots(Definition?.SpecialItems, 2),
+ BonusRewards = Slots(Definition?.GiftItems, 3),
+ HighScore = new RbrSubPacketHighScore { Score = 0, Nickname = null },
+ IsHidden = false,
+ LoserMode = false,
+ TitleAndDescription = new RbrSubPacketTitleAndDescription
+ {
+ Title = Definition?.Title ?? Definition?.Label ?? string.Empty,
+ Description = Definition?.Label ?? string.Empty
+ }
+ };
+ }
+
+ private static List Slots(IReadOnlyList? gifts, int count)
+ {
+ var slots = new List(count);
+ for (var i = 0; i < count; i++)
+ {
+ var gift = gifts != null && i < gifts.Count ? gifts[i] : null;
+ slots.Add(new RbrSubPacketItem
+ {
+ ItemId = gift?.VNum,
+ Quantity = gift?.Amount ?? 0
+ });
+ }
+
+ return slots;
+ }
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinition.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinition.cs
new file mode 100644
index 000000000..e12e34801
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinition.cs
@@ -0,0 +1,50 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using System.Collections.Generic;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public record InstanceGift(short VNum, short Amount, short Design = 0,
+ bool IsRandomRare = false, bool IsHeroic = false);
+
+ public record InstanceRoom(int Key, short VNum, byte IndexX, byte IndexY);
+
+ public record ScriptedInstanceDefinition
+ {
+ public byte Id { get; init; }
+
+ public string? Label { get; init; }
+
+ public string? Title { get; init; }
+
+ public byte LevelMinimum { get; init; }
+
+ public byte LevelMaximum { get; init; }
+
+ public byte Lives { get; init; }
+
+ public short StartX { get; init; }
+
+ public short StartY { get; init; }
+
+ public long Gold { get; init; }
+
+ public int Reputation { get; init; }
+
+ public int FamilyExperience { get; init; }
+
+ public IReadOnlyList RequiredItems { get; init; } = [];
+
+ public IReadOnlyList DrawItems { get; init; } = [];
+
+ public IReadOnlyList SpecialItems { get; init; } = [];
+
+ public IReadOnlyList GiftItems { get; init; } = [];
+
+ public IReadOnlyList Rooms { get; init; } = [];
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParser.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParser.cs
new file mode 100644
index 000000000..e84bc4d5d
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParser.cs
@@ -0,0 +1,106 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public static class ScriptedInstanceDefinitionParser
+ {
+ public static ScriptedInstanceDefinition? Parse(string? script)
+ {
+ if (string.IsNullOrWhiteSpace(script))
+ {
+ return null;
+ }
+
+ var definition = XDocument.Parse(script).Element("Definition")
+ ?? throw new FormatException("An instance script must have a Definition element at its root.");
+
+ var globals = definition.Element("Globals");
+
+ return new ScriptedInstanceDefinition
+ {
+ Id = Value(globals, "Id"),
+ Label = Text(globals, "Label"),
+ Title = Text(globals, "Title"),
+ LevelMinimum = Value(globals, "LevelMinimum"),
+ LevelMaximum = Value(globals, "LevelMaximum"),
+ Lives = Value(globals, "Lives"),
+ StartX = Value(globals, "StartX"),
+ StartY = Value(globals, "StartY"),
+ Gold = Value(globals, "Gold"),
+ Reputation = Value(globals, "Reputation"),
+ FamilyExperience = Value(globals, "Fxp"),
+ RequiredItems = Gifts(globals, "RequieredItems"),
+ DrawItems = Gifts(globals, "DrawItems"),
+ SpecialItems = Gifts(globals, "SpecialItems"),
+ GiftItems = Gifts(globals, "GiftItems"),
+ Rooms = Rooms(definition)
+ };
+ }
+
+ private static IReadOnlyList Rooms(XElement definition)
+ {
+ var rooms = definition.Element("InstanceEvents")?.Elements("CreateMap").ToList();
+ if (rooms == null)
+ {
+ return [];
+ }
+
+ return rooms.Select(room => new InstanceRoom(
+ Attribute(room, "Map"),
+ Attribute(room, "VNum"),
+ Attribute(room, "IndexX"),
+ Attribute(room, "IndexY")))
+ .ToList();
+ }
+
+ private static IReadOnlyList Gifts(XElement? globals, string listName)
+ {
+ var list = globals?.Element(listName);
+ if (list == null)
+ {
+ return [];
+ }
+
+ return list.Elements().Select(item => new InstanceGift(
+ Attribute(item, "VNum"),
+ Attribute(item, "Amount"),
+ Attribute(item, "Design"),
+ Attribute(item, "IsRandomRare"),
+ Attribute(item, "IsHeroic")))
+ .ToList();
+ }
+
+ private static T Value(XElement? globals, string name) where T : struct
+ {
+ return Attribute(globals?.Element(name), "Value");
+ }
+
+ private static string? Text(XElement? globals, string name)
+ {
+ return globals?.Element(name)?.Attribute("Value")?.Value;
+ }
+
+ private static T Attribute(XElement? element, string name) where T : struct
+ {
+ var raw = element?.Attribute(name)?.Value;
+ if (string.IsNullOrEmpty(raw))
+ {
+ return default;
+ }
+
+ return typeof(T) == typeof(bool)
+ ? (T)(object)bool.Parse(raw)
+ : (T)Convert.ChangeType(raw, typeof(T), CultureInfo.InvariantCulture);
+ }
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceRun.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceRun.cs
new file mode 100644
index 000000000..ecd252ca3
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceRun.cs
@@ -0,0 +1,30 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using NodaTime;
+using System;
+using System.Collections.Generic;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public class ScriptedInstanceRun(ScriptedInstance entrance, IReadOnlyDictionary rooms,
+ Instant startedAt)
+ {
+ public ScriptedInstance Entrance { get; } = entrance;
+
+ public IReadOnlyDictionary Rooms { get; } = rooms;
+
+ public Instant StartedAt { get; } = startedAt;
+
+ public byte LivesRemaining { get; set; } = entrance.Definition?.Lives ?? 0;
+
+ public short ReturnMapId { get; } = entrance.MapId;
+
+ public short ReturnX { get; } = entrance.PositionX;
+
+ public short ReturnY { get; } = entrance.PositionY;
+ }
+}
diff --git a/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceService.cs b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceService.cs
new file mode 100644
index 000000000..d3a0f0a85
--- /dev/null
+++ b/src/NosCore.GameObject/Services/ScriptedInstanceService/ScriptedInstanceService.cs
@@ -0,0 +1,189 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using Microsoft.Extensions.Logging;
+using NosCore.Data.Enumerations.Interaction;
+using NosCore.GameObject.Infastructure;
+using NosCore.Data.StaticEntities;
+using NodaTime;
+using System;
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using Mapster;
+using NosCore.Data.Enumerations.Map;
+using NosCore.GameObject.Services.MapInstanceGenerationService;
+using NosCore.Packets.Enumerations;
+using NosCore.Packets.ServerPackets.MiniMap;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NosCore.GameObject.Services.ScriptedInstanceService
+{
+ public class ScriptedInstanceService : IScriptedInstanceService, ISingletonService
+ {
+ private static readonly IReadOnlyList None = [];
+
+ private readonly Dictionary> _byMap;
+
+ private readonly IMapInstanceGeneratorService _mapInstanceGeneratorService;
+ private readonly IMapInstanceRegistry _mapInstanceRegistry;
+ private readonly IClock _clock;
+
+ private readonly ConcurrentDictionary _runsByRoom = new();
+ private readonly Dictionary _mapsByVNum;
+ private readonly ILogger _logger;
+
+ public ScriptedInstanceService(List scriptedInstances,
+ List maps,
+ IMapInstanceGeneratorService mapInstanceGeneratorService,
+ IMapInstanceRegistry mapInstanceRegistry,
+ IClock clock,
+ ILogger logger)
+ {
+ _mapInstanceGeneratorService = mapInstanceGeneratorService;
+ _mapInstanceRegistry = mapInstanceRegistry;
+ _clock = clock;
+ _logger = logger;
+ _mapsByVNum = maps.GroupBy(s => s.MapId).ToDictionary(s => s.Key, s => s.First());
+
+ _byMap = scriptedInstances
+ .Select(row => Build(row, logger))
+ .GroupBy(s => s.MapId)
+ .ToDictionary(s => s.Key, s => s.OrderBy(o => o.PositionY).ThenBy(o => o.PositionX).ToList());
+ }
+
+ private static ScriptedInstance Build(ScriptedInstanceDto row, ILogger logger)
+ {
+ var instance = row.Adapt();
+ try
+ {
+ instance.Definition = ScriptedInstanceDefinitionParser.Parse(row.Script);
+ return instance;
+ }
+ catch (Exception exception)
+ {
+ logger.LogError(exception,
+ "The script of ScriptedInstance {ScriptedInstanceId} on map {MapId} could not be read",
+ row.ScriptedInstanceId, row.MapId);
+ return instance;
+ }
+ }
+
+ public void Register(short mapId, short positionX, short positionY,
+ ScriptedInstanceDefinition definition)
+ {
+ var entrance = GetAt(mapId, positionX, positionY);
+ if (entrance == null)
+ {
+ // A door that was never imported: better said than silently unreachable.
+ _logger.LogError(
+ "No instance entrance at {MapId} {PositionX},{PositionY} to attach {Label} to",
+ mapId, positionX, positionY, definition.Label);
+ return;
+ }
+
+ entrance.Definition = definition;
+ }
+
+ public IReadOnlyList GetByMap(short mapId)
+ {
+ return _byMap.TryGetValue(mapId, out var entrances) ? entrances : None;
+ }
+
+ public ScriptedInstance? GetAt(short mapId, short positionX, short positionY)
+ {
+ return GetByMap(mapId)
+ .FirstOrDefault(s => s.PositionX == positionX && s.PositionY == positionY);
+ }
+
+ public async Task InstantiateAsync(ScriptedInstance entrance)
+ {
+ var rooms = entrance.Definition?.Rooms;
+ if (rooms == null || rooms.Count == 0)
+ {
+ return null;
+ }
+
+ var instanceType = entrance.Type == ScriptedInstanceType.TimeSpace
+ ? MapInstanceType.TimeSpaceInstance
+ : MapInstanceType.RaidInstance;
+
+ var built = new Dictionary();
+ foreach (var room in rooms)
+ {
+ if (!_mapsByVNum.TryGetValue(room.VNum, out var map))
+ {
+ _logger.LogError(
+ "Instance {ScriptedInstanceId} asks for map {VNum}, which does not exist",
+ entrance.ScriptedInstanceId, room.VNum);
+ await RemoveRoomsAsync(built.Values);
+ return null;
+ }
+
+ var id = Guid.NewGuid();
+ var mapInstance = _mapInstanceGeneratorService.CreateMapInstance(
+ map.Adapt(), id, false, instanceType);
+ mapInstance.MapIndexX = room.IndexX;
+ mapInstance.MapIndexY = room.IndexY;
+
+ await _mapInstanceGeneratorService.AddMapInstanceAsync(mapInstance);
+ await mapInstance.StartLifeAsync();
+ built[room.Key] = id;
+ }
+
+ var run = new ScriptedInstanceRun(entrance, built, _clock.GetCurrentInstant());
+ foreach (var id in built.Values)
+ {
+ _runsByRoom[id] = run;
+ }
+
+ return run;
+ }
+
+ public ScriptedInstanceRun? GetRun(Guid mapInstanceId)
+ {
+ return _runsByRoom.TryGetValue(mapInstanceId, out var run) ? run : null;
+ }
+
+ public async Task DisposeIfEmptyAsync(ScriptedInstanceRun run)
+ {
+ foreach (var id in run.Rooms.Values)
+ {
+ if (_mapInstanceRegistry.GetById(id)?.Sessions.Count > 0)
+ {
+ return false;
+ }
+ }
+
+ await RemoveRoomsAsync(run.Rooms.Values);
+ return true;
+ }
+
+ private async Task RemoveRoomsAsync(IEnumerable rooms)
+ {
+ foreach (var room in rooms)
+ {
+ _runsByRoom.TryRemove(room, out _);
+ await _mapInstanceGeneratorService.RemoveMapAsync(room);
+ }
+ }
+
+ public IEnumerable GenerateWp(short mapId)
+ {
+ return GetByMap(mapId)
+ .Where(s => s.Type == ScriptedInstanceType.TimeSpace)
+ .Select(s => new WpPacket
+ {
+ PositionX = s.PositionX,
+ PositionY = s.PositionY,
+ ScriptedInstanceId = s.ScriptedInstanceId,
+ PortalType = s.IsHeroic ? WpPortalType.HeroTs : WpPortalType.NormalTs,
+ LevelMinimum = s.LevelMinimum,
+ LevelMaximum = s.LevelMaximum
+ });
+ }
+ }
+}
diff --git a/src/NosCore.PacketHandlers/Game/TreqPacketHandler.cs b/src/NosCore.PacketHandlers/Game/TreqPacketHandler.cs
new file mode 100644
index 000000000..f04c28d68
--- /dev/null
+++ b/src/NosCore.PacketHandlers/Game/TreqPacketHandler.cs
@@ -0,0 +1,73 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using NosCore.Data.Enumerations.I18N;
+using NosCore.Data.Enumerations.Interaction;
+using NosCore.GameObject.Ecs.Extensions;
+using NosCore.GameObject.Infastructure;
+using NosCore.GameObject.Networking.ClientSession;
+using NosCore.GameObject.Services.MapChangeService;
+using NosCore.GameObject.Services.ScriptedInstanceService;
+using NosCore.Packets.ClientPackets.Event;
+using NosCore.Packets.Enumerations;
+using NosCore.Core.I18N;
+using NosCore.Shared.I18N;
+using System;
+using System.Threading.Tasks;
+
+namespace NosCore.PacketHandlers.Game
+{
+ public class TreqPacketHandler(IScriptedInstanceService scriptedInstanceService,
+ IMapChangeService mapChangeService,
+ IGameLanguageLocalizer gameLanguageLocalizer)
+ : PacketHandler, IWorldPacketHandler
+ {
+ public override async Task ExecuteAsync(TreqPacket packet, ClientSession session)
+ {
+ var character = session.Character;
+
+ if (Math.Abs(character.PositionX - packet.X) > 1 || Math.Abs(character.PositionY - packet.Y) > 1)
+ {
+ return;
+ }
+
+ var entrance = scriptedInstanceService.GetAt(character.MapId, (short)packet.X, (short)packet.Y);
+
+ if (entrance == null || entrance.Type != ScriptedInstanceType.TimeSpace)
+ {
+ return;
+ }
+
+ if (packet.StartPress != 1)
+ {
+ await session.SendPacketAsync(entrance.GenerateRbr());
+ return;
+ }
+
+ if (character.Level < entrance.EffectiveLevelMinimum
+ || character.Level > entrance.EffectiveLevelMaximum)
+ {
+ await session.SendPacketAsync(session.Character.GenerateSay(
+ gameLanguageLocalizer[LanguageKey.TIMESPACE_LEVEL_NOT_ALLOWED, character.AccountLanguage],
+ SayColorType.Yellow));
+ return;
+ }
+
+ var run = await scriptedInstanceService.InstantiateAsync(entrance);
+ if (run == null)
+ {
+ await session.SendPacketAsync(session.Character.GenerateSay(
+ gameLanguageLocalizer[LanguageKey.TIMESPACE_NOT_AVAILABLE, character.AccountLanguage],
+ SayColorType.Yellow));
+ return;
+ }
+
+ var firstRoom = run.Rooms[entrance.Definition!.Rooms[0].Key];
+ await mapChangeService.ChangeMapInstanceAsync(session, firstRoom,
+ entrance.Definition.StartX, entrance.Definition.StartY);
+ }
+ }
+}
diff --git a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs
index bf4d2aea6..db8fcdf51 100644
--- a/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs
+++ b/test/NosCore.GameObject.Tests/Services/MapChangeService/MapChangeServiceTests.cs
@@ -4,7 +4,9 @@
// |_|\__|\__/ |___/ \__/\__/|_|_\___|
//
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NosCore.GameObject.Services.MapInstanceGenerationService;
using Moq;
using NosCore.Algorithm.ExperienceService;
using NosCore.Algorithm.HeroExperienceService;
@@ -49,7 +51,14 @@ public async Task SetupAsync()
TestHelpers.Instance.LogLanguageLocalizer,
TestHelpers.Instance.GameLanguageLocalizer,
TestHelpers.Instance.SessionRegistry,
- new Mock().Object);
+ new Mock().Object,
+ new GameObject.Services.ScriptedInstanceService.ScriptedInstanceService(
+ new System.Collections.Generic.List(),
+ new System.Collections.Generic.List(),
+ new Mock().Object,
+ new GameObject.Services.MapInstanceGenerationService.MapInstanceRegistry(),
+ TestHelpers.Instance.Clock,
+ NullLogger.Instance));
}
[TestMethod]
diff --git a/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/InstanceDefinitionBuilderTests.cs b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/InstanceDefinitionBuilderTests.cs
new file mode 100644
index 000000000..b10523b92
--- /dev/null
+++ b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/InstanceDefinitionBuilderTests.cs
@@ -0,0 +1,72 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NosCore.GameObject.Services.ScriptedInstanceService;
+using System.Linq;
+
+namespace NosCore.GameObject.Tests.Services.ScriptedInstanceService
+{
+ [TestClass]
+ public class InstanceDefinitionBuilderTests
+ {
+ [TestMethod]
+ public void AnInstanceReadsAsOneSentencePerFact()
+ {
+ var definition = InstanceDefinitionBuilder
+ .Named(id: 3, label: "Cuby", title: "Mother Cuby")
+ .ForLevels(20, 45)
+ .WithLives(3)
+ .StartingAt(12, 34)
+ .Rewarding(gold: 15000, reputation: 200)
+ .WithRoom(2004, out var entrance)
+ .WithRoom(2005, out var lair, indexX: 1)
+ .Requiring(1000, 2)
+ .Drawing(1012, 3, design: 7, randomRare: true)
+ .WithSpecialReward(2282, 1, heroic: true)
+ .WithReward(1030, 5)
+ .Build();
+
+ Assert.AreEqual("Cuby", definition.Label);
+ Assert.AreEqual(20, definition.LevelMinimum);
+ Assert.AreEqual(15000L, definition.Gold);
+ Assert.AreEqual(2, definition.Rooms.Count);
+ Assert.AreEqual(1000, definition.RequiredItems.Single().VNum);
+ Assert.IsTrue(definition.SpecialItems.Single().IsHeroic);
+
+ // Keys are handed out, not written down: the compiler carries what the XML did not.
+ Assert.AreNotEqual(entrance, lair);
+ Assert.AreEqual(2004, definition.Rooms.Single(s => s.Key == entrance).VNum);
+ Assert.AreEqual(2005, definition.Rooms.Single(s => s.Key == lair).VNum);
+ }
+
+ [TestMethod]
+ public void AnInstanceThatGivesNothingIsStillAnInstance()
+ {
+ var definition = InstanceDefinitionBuilder.Named(1, "Empty", "Empty")
+ .WithRoom(2004, out _)
+ .Build();
+
+ Assert.AreEqual(0, definition.DrawItems.Count);
+ Assert.AreEqual(0, definition.Gold);
+ Assert.AreEqual(1, definition.Rooms.Count);
+ }
+
+ [TestMethod]
+ public void TheRoomsKeepTheOrderTheyWereWrittenIn()
+ {
+ // The first room is where the party lands, so order is the author's.
+ var definition = InstanceDefinitionBuilder.Named(1, "Three", "Three")
+ .WithRoom(30, out _)
+ .WithRoom(10, out _)
+ .WithRoom(20, out _)
+ .Build();
+
+ CollectionAssert.AreEqual(new short[] { 30, 10, 20 },
+ definition.Rooms.Select(s => s.VNum).ToArray());
+ }
+ }
+}
diff --git a/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParserTests.cs b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParserTests.cs
new file mode 100644
index 000000000..8326ce9d3
--- /dev/null
+++ b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceDefinitionParserTests.cs
@@ -0,0 +1,271 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NosCore.GameObject.Services.MapInstanceGenerationService;
+using Moq;
+using NosCore.Data.Enumerations.Interaction;
+using NosCore.Data.StaticEntities;
+using NosCore.GameObject.Services.ScriptedInstanceService;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ServiceUnderTest = NosCore.GameObject.Services.ScriptedInstanceService.ScriptedInstanceService;
+
+namespace NosCore.GameObject.Tests.Services.ScriptedInstanceService
+{
+ [TestClass]
+ public class ScriptedInstanceDefinitionParserTests
+ {
+ private const string FullScript = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ [TestMethod]
+ public void TheGlobalsAreReadOffTheValueAttribute()
+ {
+ var definition = ScriptedInstanceDefinitionParser.Parse(FullScript)!;
+
+ Assert.AreEqual(3, definition.Id);
+ Assert.AreEqual("Cuby", definition.Label);
+ Assert.AreEqual("Mother Cuby", definition.Title);
+ Assert.AreEqual(20, definition.LevelMinimum);
+ Assert.AreEqual(45, definition.LevelMaximum);
+ Assert.AreEqual(3, definition.Lives);
+ Assert.AreEqual(12, definition.StartX);
+ Assert.AreEqual(34, definition.StartY);
+ Assert.AreEqual(15000L, definition.Gold);
+ Assert.AreEqual(200, definition.Reputation);
+ Assert.AreEqual(50, definition.FamilyExperience);
+ }
+
+ [TestMethod]
+ public void EachRewardListIsReadIntoItsOwnPlace()
+ {
+ var definition = ScriptedInstanceDefinitionParser.Parse(FullScript)!;
+
+ Assert.AreEqual(1000, definition.RequiredItems.Single().VNum);
+ Assert.AreEqual(2, definition.DrawItems.Count);
+ Assert.AreEqual(2282, definition.SpecialItems.Single().VNum);
+ Assert.AreEqual(1030, definition.GiftItems.Single().VNum);
+ }
+
+ [TestMethod]
+ public void AGiftKeepsItsRarityFlags()
+ {
+ var drawn = ScriptedInstanceDefinitionParser.Parse(FullScript)!.DrawItems[0];
+
+ Assert.AreEqual(1012, drawn.VNum);
+ Assert.AreEqual(3, drawn.Amount);
+ Assert.AreEqual(7, drawn.Design);
+ Assert.IsTrue(drawn.IsRandomRare);
+ Assert.IsFalse(drawn.IsHeroic);
+ Assert.IsTrue(ScriptedInstanceDefinitionParser.Parse(FullScript)!.SpecialItems[0].IsHeroic);
+ }
+
+ [TestMethod]
+ public void TheRoomsKeepTheKeyTheRestOfTheScriptRefersToThemBy()
+ {
+ var rooms = ScriptedInstanceDefinitionParser.Parse(FullScript)!.Rooms;
+
+ Assert.AreEqual(2, rooms.Count);
+ Assert.AreEqual(1, rooms[0].Key);
+ Assert.AreEqual(2004, rooms[0].VNum);
+ Assert.AreEqual(1, rooms[1].IndexX);
+ Assert.AreEqual(0, rooms[1].IndexY);
+ }
+
+ [TestMethod]
+ public void AScriptThatLeavesOutTheOptionalPartsStillLoads()
+ {
+ var definition = ScriptedInstanceDefinitionParser.Parse("""
+
+
+
+
+
+ """)!;
+
+ Assert.AreEqual(1, definition.LevelMinimum);
+ Assert.AreEqual(0, definition.Reputation);
+ Assert.IsNull(definition.Title);
+ Assert.AreEqual(0, definition.DrawItems.Count);
+ Assert.AreEqual(0, definition.Rooms.Count);
+ }
+
+ [TestMethod]
+ public void ARowWithNoScriptHasNoDefinition()
+ {
+ Assert.IsNull(ScriptedInstanceDefinitionParser.Parse(null));
+ Assert.IsNull(ScriptedInstanceDefinitionParser.Parse(" "));
+ }
+
+ [TestMethod]
+ public void BrokenXmlIsNotSwallowed()
+ {
+ Assert.ThrowsExactly(() =>
+ ScriptedInstanceDefinitionParser.Parse(""));
+ Assert.ThrowsExactly(() =>
+ ScriptedInstanceDefinitionParser.Parse(""));
+ }
+
+ [TestMethod]
+ public void AnUnreadableScriptLeavesTheDoorStandingWithoutOne()
+ {
+ var service = new ServiceUnderTest([
+ new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = 1, MapId = 1, PositionX = 5, PositionY = 6,
+ Type = ScriptedInstanceType.TimeSpace, Script = ""
+ }
+ ], [], new Mock().Object, new MapInstanceRegistry(), NodaTime.SystemClock.Instance, NullLogger.Instance);
+
+ var entrance = service.GetAt(1, 5, 6);
+
+ Assert.IsNotNull(entrance);
+ Assert.IsNull(entrance.Definition);
+ }
+
+ [TestMethod]
+ public void TheScriptsLevelRangeWinsOverTheImportedOne()
+ {
+ var service = new ServiceUnderTest([
+ new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = 1, MapId = 1, PositionX = 5, PositionY = 6,
+ Type = ScriptedInstanceType.TimeSpace, LevelMinimum = 10, LevelMaximum = 99,
+ Script = """
+
+
+
+ """
+ }
+ ], [], new Mock().Object, new MapInstanceRegistry(), NodaTime.SystemClock.Instance, NullLogger.Instance);
+
+ var entrance = service.GetAt(1, 5, 6)!;
+
+ Assert.AreEqual(55, entrance.EffectiveLevelMinimum);
+ Assert.AreEqual(60, entrance.EffectiveLevelMaximum);
+ }
+
+ [TestMethod]
+ public void WithoutAScriptTheImportedLevelRangeIsWhatThereIs()
+ {
+ var service = new ServiceUnderTest([
+ new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = 1, MapId = 1, PositionX = 5, PositionY = 6,
+ Type = ScriptedInstanceType.TimeSpace, LevelMinimum = 10, LevelMaximum = 99
+ }
+ ], [], new Mock().Object, new MapInstanceRegistry(), NodaTime.SystemClock.Instance, NullLogger.Instance);
+
+ var entrance = service.GetAt(1, 5, 6)!;
+
+ Assert.AreEqual(10, entrance.EffectiveLevelMinimum);
+ Assert.AreEqual(99, entrance.EffectiveLevelMaximum);
+ }
+
+ [TestMethod]
+ public void TheEntryPanelPadsEveryRewardRowToTheWidthTheClientExpects()
+ {
+ var instance = new GameObject.Services.ScriptedInstanceService.ScriptedInstance
+ {
+ Definition = ScriptedInstanceDefinitionParser.Parse(FullScript)
+ };
+
+ var packet = instance.GenerateRbr();
+
+ Assert.AreEqual(5, packet.DrawRewards!.Count);
+ Assert.AreEqual(2, packet.SpecialRewards!.Count);
+ Assert.AreEqual(3, packet.BonusRewards!.Count);
+ Assert.AreEqual(1012, packet.DrawRewards[0]!.ItemId);
+ Assert.IsNull(packet.DrawRewards[4]!.ItemId, "an empty slot is a null id, which the serializer writes as -1");
+ }
+
+ [TestMethod]
+ public void TheEntryPanelChargesOnceForEveryUnitAsked()
+ {
+ var instance = new GameObject.Services.ScriptedInstanceService.ScriptedInstance
+ {
+ Definition = ScriptedInstanceDefinitionParser.Parse(FullScript)
+ };
+
+ Assert.AreEqual(2, instance.GenerateRbr().RequiredSeeds);
+ }
+
+ [TestMethod]
+ public void TheEntryPanelNeverClaimsProgressNobodyRecorded()
+ {
+ var packet = new GameObject.Services.ScriptedInstanceService.ScriptedInstance
+ {
+ Definition = ScriptedInstanceDefinitionParser.Parse(FullScript)
+ }.GenerateRbr();
+
+ Assert.IsFalse(packet.Completed);
+ Assert.AreEqual(0, packet.HighScore!.Score);
+ Assert.IsNull(packet.HighScore.Nickname);
+ }
+
+ [TestMethod]
+ public void AnEntranceWithNoScriptStillProducesAPanel()
+ {
+ var packet = new GameObject.Services.ScriptedInstanceService.ScriptedInstance
+ {
+ LevelMinimum = 30,
+ LevelMaximum = 55
+ }.GenerateRbr();
+
+ Assert.AreEqual(30, packet.MinMaxLevel!.MinLevel);
+ Assert.AreEqual(55, packet.MinMaxLevel.MaxLevel);
+ Assert.AreEqual(5, packet.DrawRewards!.Count);
+ }
+
+ [TestMethod]
+ public void AHeroTimeSpaceSaysSoOnItsPanel()
+ {
+ Assert.AreEqual(NosCore.Packets.Enumerations.RbrPacketTsType.HeroMission,
+ new GameObject.Services.ScriptedInstanceService.ScriptedInstance { IsHeroic = true }
+ .GenerateRbr().TsBasicInfo!.TsType);
+ Assert.AreEqual(NosCore.Packets.Enumerations.RbrPacketTsType.MainMission,
+ new GameObject.Services.ScriptedInstanceService.ScriptedInstance()
+ .GenerateRbr().TsBasicInfo!.TsType);
+ }
+ }
+}
diff --git a/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceServiceTests.cs b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceServiceTests.cs
new file mode 100644
index 000000000..7c56cac15
--- /dev/null
+++ b/test/NosCore.GameObject.Tests/Services/ScriptedInstanceService/ScriptedInstanceServiceTests.cs
@@ -0,0 +1,227 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+//
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NosCore.GameObject.Services.MapInstanceGenerationService;
+using Moq;
+using NosCore.Data.Enumerations.Interaction;
+using NosCore.Data.StaticEntities;
+using NosCore.Packets.Enumerations;
+using System.Collections.Generic;
+using NosCore.Tests.Shared;
+using System.Threading.Tasks;
+using System;
+using System.Linq;
+using ServiceUnderTest = NosCore.GameObject.Services.ScriptedInstanceService.ScriptedInstanceService;
+
+namespace NosCore.GameObject.Tests.Services.ScriptedInstanceService
+{
+ [TestClass]
+ public class ScriptedInstanceServiceTests
+ {
+ [TestInitialize]
+ public Task SetupAsync()
+ {
+ return TestHelpers.ResetAsync();
+ }
+
+ private static ServiceUnderTest Build(params ScriptedInstanceDto[] entrances)
+ {
+ return new ServiceUnderTest(entrances.ToList(), [], new Mock().Object, new MapInstanceRegistry(), TestHelpers.Instance.Clock, NullLogger.Instance);
+ }
+
+ private static ScriptedInstanceDto TimeSpace(short id, short mapId, short x, short y,
+ bool heroic = false, byte levelMinimum = 1)
+ {
+ return new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = id,
+ MapId = mapId,
+ PositionX = x,
+ PositionY = y,
+ Type = ScriptedInstanceType.TimeSpace,
+ IsHeroic = heroic,
+ LevelMinimum = levelMinimum,
+ LevelMaximum = 99
+ };
+ }
+
+ [TestMethod]
+ public void AMapWithNoEntrancesAnswersEmptyRatherThanThrowing()
+ {
+ var service = Build(TimeSpace(1, 1, 10, 10));
+
+ Assert.AreEqual(0, service.GetByMap(2).Count);
+ Assert.AreEqual(0, service.GenerateWp(2).Count());
+ Assert.IsNull(service.GetAt(2, 10, 10));
+ }
+
+ [TestMethod]
+ public void AnEntranceIsFoundByTheSquareItStandsOn()
+ {
+ var service = Build(TimeSpace(1, 1, 10, 20), TimeSpace(2, 1, 30, 40));
+
+ Assert.AreEqual(2, service.GetAt(1, 30, 40)!.ScriptedInstanceId);
+ Assert.IsNull(service.GetAt(1, 30, 41));
+ }
+
+ [TestMethod]
+ public void TheMarkerCarriesTheRowKeyTheClientWillSendBack()
+ {
+ var packet = Build(TimeSpace(42, 1, 10, 20, levelMinimum: 55)).GenerateWp(1).Single();
+
+ Assert.AreEqual(42, packet.ScriptedInstanceId);
+ Assert.AreEqual(10, packet.PositionX);
+ Assert.AreEqual(20, packet.PositionY);
+ Assert.AreEqual(55, packet.LevelMinimum);
+ Assert.AreEqual(99, packet.LevelMaximum);
+ }
+
+ [TestMethod]
+ public void AHeroTimeSpaceIsMarkedAsOne()
+ {
+ Assert.AreEqual(WpPortalType.HeroTs,
+ Build(TimeSpace(1, 1, 10, 20, heroic: true)).GenerateWp(1).Single().PortalType);
+ Assert.AreEqual(WpPortalType.NormalTs,
+ Build(TimeSpace(1, 1, 10, 20)).GenerateWp(1).Single().PortalType);
+ }
+
+ [TestMethod]
+ public void NoMarkerEverClaimsThePlayerHasClearedIt()
+ {
+ var service = Build(
+ TimeSpace(1, 1, 10, 20),
+ TimeSpace(2, 1, 30, 40, heroic: true));
+
+ Assert.IsFalse(service.GenerateWp(1)
+ .Any(s => s.PortalType is WpPortalType.NormalTsDone or WpPortalType.HeroTsDone));
+ }
+
+ [TestMethod]
+ public void ARaidEntranceIsNotAMinimapMarker()
+ {
+ var service = Build(
+ TimeSpace(1, 2500, 10, 20),
+ new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = 2,
+ MapId = 2500,
+ PositionX = 24,
+ PositionY = 3,
+ Type = ScriptedInstanceType.Raid
+ });
+
+ Assert.AreEqual(2, service.GetByMap(2500).Count);
+ Assert.AreEqual(1, service.GenerateWp(2500).Single().ScriptedInstanceId);
+ }
+
+ private static ScriptedInstanceDto Scripted(short id, short mapId, short x, short y, string script,
+ ScriptedInstanceType type = ScriptedInstanceType.TimeSpace)
+ {
+ return new ScriptedInstanceDto
+ {
+ ScriptedInstanceId = id, MapId = mapId, PositionX = x, PositionY = y,
+ Type = type, Script = script
+ };
+ }
+
+ private static (ServiceUnderTest Service, Mock Generator, List Removed)
+ BuildWithMaps(ScriptedInstanceDto entrance, params short[] existingMapVNums)
+ {
+ var removed = new List();
+ var generator = new Mock();
+ generator
+ .Setup(s => s.CreateMapInstance(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .Returns((GameObject.Map.Map map, Guid id, bool shop, NosCore.Data.Enumerations.Map.MapInstanceType type)
+ => TestHelpers.Instance.MapInstanceGeneratorService.CreateMapInstance(map, id, shop, type));
+ generator.Setup(s => s.AddMapInstanceAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+ generator.Setup(s => s.RemoveMapAsync(It.IsAny()))
+ .Callback(removed.Add).Returns(Task.CompletedTask);
+
+ var maps = existingMapVNums.Select(v => new MapDto
+ {
+ MapId = v,
+ NameI18NKey = "instanceRoom",
+ Data = [4, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+ }).ToList();
+ return (new ServiceUnderTest([entrance], maps, generator.Object,
+ TestHelpers.Instance.MapInstanceRegistry, TestHelpers.Instance.Clock,
+ NullLogger.Instance),
+ generator, removed);
+ }
+
+ private const string TwoRooms = """
+
+
+
+
+
+
+
+ """;
+
+ [TestMethod]
+ public async Task EachRoomOfTheDefinitionBecomesItsOwnMapInstanceAsync()
+ {
+ var (service, _, _) = BuildWithMaps(Scripted(1, 1, 5, 6, TwoRooms), 2004, 2005);
+
+ var run = await service.InstantiateAsync(service.GetAt(1, 5, 6)!);
+
+ Assert.IsNotNull(run);
+ var rooms = run.Rooms;
+ Assert.AreEqual(2, rooms.Count);
+ Assert.IsTrue(rooms.ContainsKey(1));
+ Assert.IsTrue(rooms.ContainsKey(2));
+ Assert.AreNotEqual(rooms[1], rooms[2]);
+ }
+
+ [TestMethod]
+ public async Task TwoPartiesEnteringTheSameDoorDoNotMeetAsync()
+ {
+ var (service, _, _) = BuildWithMaps(Scripted(1, 1, 5, 6, TwoRooms), 2004, 2005);
+ var entrance = service.GetAt(1, 5, 6)!;
+
+ var first = await service.InstantiateAsync(entrance);
+ var second = await service.InstantiateAsync(entrance);
+
+ Assert.AreNotEqual(first!.Rooms[1], second!.Rooms[1]);
+ }
+
+ [TestMethod]
+ public async Task ADoorWithNoScriptBuildsNothingAsync()
+ {
+ var (service, generator, _) = BuildWithMaps(Scripted(1, 1, 5, 6, null!));
+
+ Assert.IsNull(await service.InstantiateAsync(service.GetAt(1, 5, 6)!));
+ generator.Verify(s => s.AddMapInstanceAsync(It.IsAny()),
+ Times.Never);
+ }
+
+ [TestMethod]
+ public async Task AMissingRoomUndoesTheRoomsAlreadyBuiltAsync()
+ {
+ var (service, _, removed) = BuildWithMaps(Scripted(1, 1, 5, 6, TwoRooms), 2004);
+
+ var run = await service.InstantiateAsync(service.GetAt(1, 5, 6)!);
+
+ Assert.IsNull(run);
+ Assert.AreEqual(1, removed.Count, "the room that was built before the failure has to be torn down");
+ }
+
+ [TestMethod]
+ public async Task TearingDownRemovesEveryRoomAsync()
+ {
+ var (service, _, removed) = BuildWithMaps(Scripted(1, 1, 5, 6, TwoRooms), 2004, 2005);
+ var run = await service.InstantiateAsync(service.GetAt(1, 5, 6)!);
+
+ Assert.IsTrue(await service.DisposeIfEmptyAsync(run!));
+ Assert.AreEqual(2, removed.Count);
+ }
+ }
+}
diff --git a/test/NosCore.Tests.Shared/TestHelpers.cs b/test/NosCore.Tests.Shared/TestHelpers.cs
index 6d3be481b..c2acbca19 100644
--- a/test/NosCore.Tests.Shared/TestHelpers.cs
+++ b/test/NosCore.Tests.Shared/TestHelpers.cs
@@ -183,6 +183,8 @@ private TestHelpers()
public MapInstanceGeneratorService MapInstanceGeneratorService { get; set; } = null!;
+ public IMapInstanceRegistry MapInstanceRegistry { get; set; } = null!;
+
public MapInstanceAccessorService MapInstanceAccessorService { get; set; } = null!;
public MapChangeService MapChangeService { get; set; } = null!;
public IHeuristic DistanceCalculator { get; set; } = new OctileDistanceHeuristic();
@@ -243,11 +245,13 @@ private async Task GenerateMapInstanceProviderAsync()
var npc = new MapNpcDto();
await MapNpcDao.TryInsertOrUpdateAsync(npc);
var mapInstanceRegistry = new MapInstanceRegistry();
+ MapInstanceRegistry = mapInstanceRegistry;
MapInstanceAccessorService = new MapInstanceAccessorService(mapInstanceRegistry);
var minilandServiceMock = new Mock();
minilandServiceMock.Setup(s => s.GetMinilandPortals(It.IsAny())).Returns(new List());
MapChangeService = new MapChangeService(new Mock().Object, new Mock().Object, new Mock().Object,
- MapInstanceAccessorService, Instance.Clock, Instance.LogLanguageLocalizer, minilandServiceMock.Object, NullLogger.Instance, Instance.LogLanguageLocalizer, Instance.GameLanguageLocalizer, SessionRegistry, new Mock().Object);
+ MapInstanceAccessorService, Instance.Clock, Instance.LogLanguageLocalizer, minilandServiceMock.Object, NullLogger.Instance, Instance.LogLanguageLocalizer, Instance.GameLanguageLocalizer, SessionRegistry, new Mock().Object,
+ new GameObject.Services.ScriptedInstanceService.ScriptedInstanceService(new List(), new List(), new Mock().Object, new MapInstanceRegistry(), TestHelpers.Instance.Clock, NullLogger.Instance));
var mapChangeService = MapChangeService;
var instanceGeneratorService = new MapInstanceGeneratorService(new List { map, mapShop, miniland }, new List(), new List(), new List(),
MapItemProvider,