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 = """ + + + +