Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions documentation/manual-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ This one is easy to eyeball — before the fix everything came back a hundred ti

## Character

### Specialist cooldown

- [ ] Untransform, relog within 30 seconds: wearing the SP again is refused with the
side-effect message until the window ends
- [ ] Wait out the 30 seconds in-game: the red "side effect gone" line and the cleared
cooldown arrive roughly on time
- [ ] Server restart inside the window keeps the refusal on the remaining seconds

### Mounts

- [ ] Mount a vehicle: the sprite changes **and** you move faster
Expand Down
3 changes: 3 additions & 0 deletions src/NosCore.Database/Entities/Character.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// |_|\__|\__/ |___/ \__/\__/|_|_\___|
//

using NodaTime;
using NosCore.Data.Enumerations.Character;
using NosCore.Database.Entities.Base;
using NosCore.Packets.Enumerations;
Expand Down Expand Up @@ -135,6 +136,8 @@ public Character()

public short MapId { get; set; }

public Instant? LastSp { get; set; }

public short MapX { get; set; }

public short MapY { get; set; }
Expand Down
4,196 changes: 4,196 additions & 0 deletions src/NosCore.Database/Migrations/20260830103418_AddCharacterLastSp.Designer.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;

#nullable disable

namespace NosCore.Database.Migrations
{
/// <inheritdoc />
public partial class AddCharacterLastSp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Instant>(
name: "LastSp",
table: "Character",
type: "timestamp with time zone",
nullable: true);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastSp",
table: "Character");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
Expand Down Expand Up @@ -420,6 +420,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<long>("JobLevelXp")
.HasColumnType("bigint");

b.Property<Instant?>("LastSp")
.HasColumnType("timestamp with time zone");

b.Property<byte>("Level")
.HasColumnType("smallint");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public class MapInstance : IBroadcastable, IDisposable
private readonly IRegenerationService? _regenerationService;
private readonly IBattleService? _battleService;
private readonly IVitalityService? _vitalityService;
private readonly TransformationService.ISpCooldownNotificationService? _spCooldownNotificationService;
private readonly ConcurrentDictionary<long, (MonsterComponentBundle Monster, Instant RespawnAt)> _pendingRespawns = new();

public MapWorld EcsWorld { get; }
Expand All @@ -73,7 +74,8 @@ public MapInstance(Map.Map map, Guid guid, bool shopAllowed, MapInstanceType typ
IMapItemGenerationService mapItemGenerationService, ILogger<MapInstance> logger, IClock clock, IMapChangeService mapChangeService,
ISessionGroupFactory sessionGroupFactory, ISessionRegistry sessionRegistry, IHeuristic distanceCalculator,
IMonsterAi? monsterAi = null, IBuffService? buffService = null, IRegenerationService? regenerationService = null,
IBattleService? battleService = null, IVitalityService? vitalityService = null)
IBattleService? battleService = null, IVitalityService? vitalityService = null,
TransformationService.ISpCooldownNotificationService? spCooldownNotificationService = null)
{
LastPackets = new ConcurrentQueue<IPacket>();
XpRate = 1;
Expand All @@ -100,6 +102,7 @@ public MapInstance(Map.Map map, Guid guid, bool shopAllowed, MapInstanceType typ
_regenerationService = regenerationService;
_battleService = battleService;
_vitalityService = vitalityService;
_spCooldownNotificationService = spCooldownNotificationService;
EcsWorld = new MapWorld();
}

Expand Down Expand Up @@ -445,6 +448,11 @@ await _vitalityService.RefreshAndNotifyAsync(session.Character)
await _battleService.TickCooldownResetsAsync(this).ConfigureAwait(false);
}

if (_spCooldownNotificationService != null)
{
await _spCooldownNotificationService.TickAsync(this);
}

await SweepPendingRespawnsAsync().ConfigureAwait(false);
}
catch (Exception e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public class MapInstanceGeneratorService(List<MapDto> maps, List<NpcMonsterDto>
NosCore.GameObject.Services.BattleService.IBuffService buffService,
NosCore.GameObject.Services.BattleService.IRegenerationService regenerationService,
NosCore.GameObject.Services.BattleService.IBattleService battleService,
NosCore.GameObject.Services.BattleService.IVitalityService vitalityService)
NosCore.GameObject.Services.BattleService.IVitalityService vitalityService,
TransformationService.ISpCooldownNotificationService spCooldownNotificationService)
: IMapInstanceGeneratorService
{
public Task AddMapInstanceAsync(MapInstance mapInstance)
Expand Down Expand Up @@ -130,7 +131,7 @@ public MapInstance CreateMapInstance(Map.Map map, Guid guid, bool shopAllowed, M
{
return new MapInstance(map, guid, shopAllowed, normalInstance, mapItemGenerationService,
loggerFactory.CreateLogger<MapInstance>(), clock,
mapChangeService, sessionGroupFactory, sessionRegistry, distanceCalculator, monsterAi, buffService, regenerationService, battleService, vitalityService);
mapChangeService, sessionGroupFactory, sessionRegistry, distanceCalculator, monsterAi, buffService, regenerationService, battleService, vitalityService, spCooldownNotificationService);
}

private async Task LoadPortalsAsync(MapInstance mapInstance, List<PortalDto> portals)
Expand Down
1 change: 1 addition & 0 deletions src/NosCore.GameObject/Services/SaveService/SaveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public async Task SaveAsync(ClientSession session)
characterDto.MapX = character.PositionX;
characterDto.MapY = character.PositionY;
}
characterDto.LastSp = character.LastSp;
characterDto.SpPoint = character.SpPoint;
characterDto.SpAdditionPoint = character.SpAdditionPoint;
characterDto.CurrentScriptId = character.CurrentScriptId;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// __ _ __ __ ___ __ ___ ___
// | \| |/__\ /' _/ / _//__\| _ \ __|
// | | ' | \/ |`._`.| \_| \/ | v / _|
// |_|\__|\__/ |___/ \__/\__/|_|_\___|
//

using NodaTime;
using NosCore.GameObject.Infastructure;
using NosCore.GameObject.Networking.ClientSession;
using NosCore.GameObject.Services.MapInstanceGenerationService;
using NosCore.Packets.Enumerations;
using NosCore.Packets.ServerPackets.Chats;
using NosCore.Packets.ServerPackets.Specialists;
using NosCore.Shared.Enumerations;
using System.Collections.Concurrent;
using System.Threading.Tasks;

namespace NosCore.GameObject.Services.TransformationService
{
public interface ISpCooldownNotificationService
{
void Schedule(ClientSession session, Instant end);

Task TickAsync(MapInstance mapInstance);
}

// The cooldown itself is gated by LastSp arithmetic; this only delivers the
// "side effect gone" packets when the window elapses. Swept by the owning map's
// life loop instead of a detached timer so a disconnected session is dropped
// rather than raced.
public sealed class SpCooldownNotificationService(IClock clock) : ISpCooldownNotificationService, ISingletonService
{
private readonly ConcurrentDictionary<long, (ClientSession Session, Instant End)> _pending = new();

public void Schedule(ClientSession session, Instant end)
{
_pending[session.Character.CharacterId] = (session, end);
}

public async Task TickAsync(MapInstance mapInstance)
{
if (_pending.IsEmpty)
{
return;
}

var now = clock.GetCurrentInstant();
foreach (var (characterId, entry) in _pending)
{
if (!entry.Session.HasPlayerEntity)
{
_pending.TryRemove(characterId, out _);
continue;
}

if (entry.Session.Character.MapInstance.MapInstanceId != mapInstance.MapInstanceId)
{
continue;
}

if (entry.End > now)
{
continue;
}

if (!_pending.TryRemove(characterId, out _))
{
continue;
}

await entry.Session.SendPacketAsync(new SayiPacket
{
VisualType = VisualType.Player,
VisualId = characterId,
Type = SayColorType.Red,
Message = Game18NConstString.TransformationSideEffectGone
});
await entry.Session.SendPacketAsync(new SdPacket { Cooldown = 0 });
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ public class TransformationService(IClock clock, IExperienceService experienceSe
IJobExperienceService jobExperienceService, IHeroExperienceService heroExperienceService, ILogger<TransformationService> logger,
ILogLanguageLocalizer<LogLanguageKey> logLanguage, IOptions<WorldConfiguration> worldConfiguration,
SpeedCalculationService.ISpeedCalculationService speedCalculationService,
SkillService.ISkillService skillService)
SkillService.ISkillService skillService,
ISpCooldownNotificationService spCooldownNotificationService)
: ITransformationService
{
public const short SpCooldownSeconds = 30;

public async Task RemoveSpAsync(ClientSession session)
{
var character = session.Character;
Expand All @@ -45,7 +48,7 @@ public async Task RemoveSpAsync(ClientSession session)
character.MorphDesign = 0;

await skillService.UnloadSpecialistSkillsAsync(character);
character.SpCooldown = 30;
character.SpCooldown = SpCooldownSeconds;

var characterId = character.CharacterId;
var spCooldown = character.SpCooldown;
Expand Down Expand Up @@ -76,19 +79,7 @@ await mapInstance.SendPacketAsync(new GuriPacket
});
await session.SendPacketAsync(statPacket);

async Task CoolDown()
{
await session.SendPacketAsync(new SayiPacket
{
VisualType = VisualType.Player,
VisualId = characterId,
Type = SayColorType.Red,
Message = Game18NConstString.TransformationSideEffectGone
});
await session.SendPacketAsync(new SdPacket { Cooldown = 0 });
}

Observable.Timer(TimeSpan.FromMilliseconds(spCooldown * 1000)).Select(_ => CoolDown()).Subscribe();
spCooldownNotificationService.Schedule(session, clock.GetCurrentInstant().Plus(Duration.FromSeconds(spCooldown)));
}

public async Task ChangeSpAsync(ClientSession session)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// __ _ __ __ ___ __ ___ ___
// __ _ __ __ ___ __ ___ ___
// | \| |/__\ /' _/ / _//__\| _ \ __|
// | | ' | \/ |`._`.| \_| \/ | v / _|
// |_|\__|\__/ |___/ \__/\__/|_|_\___|
Expand Down Expand Up @@ -177,7 +177,7 @@ await pubSubHub.SubscribeAsync(new Subscriber
false,
true,
now,
now,
characterDto.LastSp ?? now,
0
);

Expand Down Expand Up @@ -207,6 +207,12 @@ await pubSubHub.SubscribeAsync(new Subscriber
var character = clientSession.Character;
group.JoinGroup(character);

if (characterDto.LastSp is { } lastSp
&& now < lastSp.Plus(Duration.FromSeconds(GameObject.Services.TransformationService.TransformationService.SpCooldownSeconds)))
{
character.SpCooldown = GameObject.Services.TransformationService.TransformationService.SpCooldownSeconds;
}

#pragma warning disable CS0618
await clientSession.SendPacketsAsync(character.GenerateInv(logger, logLanguage));
#pragma warning restore CS0618
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public async Task SetupAsync()
TestHelpers.Instance.LogLanguageLocalizer,
TestHelpers.Instance.WorldConfiguration,
new GameObject.Services.SpeedCalculationService.SpeedCalculationService(new SpeedService()),
new Mock<GameObject.Services.SkillService.ISkillService>().Object);
new Mock<GameObject.Services.SkillService.ISkillService>().Object,
new GameObject.Services.TransformationService.SpCooldownNotificationService(TestHelpers.Instance.Clock));
}

[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public async Task SetupAsync()
new Mock<IJobExperienceService>().Object, new Mock<IHeroExperienceService>().Object,
new Mock<ILogger<TransformationService>>().Object, TestHelpers.Instance.LogLanguageLocalizer, TestHelpers.Instance.WorldConfiguration,
new NosCore.GameObject.Services.SpeedCalculationService.SpeedCalculationService(new SpeedService()),
new Mock<NosCore.GameObject.Services.SkillService.ISkillService>().Object),
new Mock<NosCore.GameObject.Services.SkillService.ISkillService>().Object,
new SpCooldownNotificationService(TestHelpers.Instance.Clock)),
TestHelpers.Instance.GameLanguageLocalizer);
}

Expand Down
3 changes: 2 additions & 1 deletion test/NosCore.Tests.Shared/TestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ private async Task GenerateMapInstanceProviderAsync()
new Mock<NosCore.GameObject.Services.BattleService.IBuffService>().Object,
new Mock<NosCore.GameObject.Services.BattleService.IRegenerationService>().Object,
new Mock<NosCore.GameObject.Services.BattleService.IBattleService>().Object,
new Mock<NosCore.GameObject.Services.BattleService.IVitalityService>().Object);
new Mock<NosCore.GameObject.Services.BattleService.IVitalityService>().Object,
new Mock<NosCore.GameObject.Services.TransformationService.ISpCooldownNotificationService>().Object);
await instanceGeneratorService.InitializeAsync();
await instanceGeneratorService.AddMapInstanceAsync(new MapInstance(miniland, MinilandId, false,
MapInstanceType.NormalInstance, MapItemProvider, NullLogger<MapInstance>.Instance, Clock, mapChangeService, SessionGroupFactory, SessionRegistry, Instance.DistanceCalculator));
Expand Down
Loading