Skip to content
Merged
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
17 changes: 16 additions & 1 deletion documentation/manual-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ The maximum comes from the parser, so re-run it on an existing database first.
- [ ] Sit with a Roasted Herring digesting and let a monster hit you: you stand up and the
green numbers stop

### Pets

Capture is skill 209, which every new character has. Weaken a monster at or below your level to
under half health first.

- [ ] With no pet out, catch one: "capture successful" shows, the pet appears where the monster
stood, it is in the party panel, and the pet window lists it
- [ ] With a pet already out, catch another: it joins the pet window only
- [ ] A failed roll says the capture failed; a monster above your level or above half health
says why it cannot be caught
- [ ] Open the pet window: the list stays, it does not empty
- [ ] Change map: the pet comes along and stays in the party panel
- [ ] Log out and back in: every pet, its level and loyalty are still there
- [ ] With a second client on the map, log out: your pet disappears from their screen too

### Food and snacks

Lose some HP first, otherwise there is nothing to watch.
Expand Down Expand Up @@ -202,6 +217,6 @@ Recorded so nobody spends time hunting for them:
- **Family experience and the `gidx` family id** — the family feature itself is not merged
yet, so neither the corrected curve nor the flattened packet field has a path to the
client.
- **Mates and instances** — same, still open.
- **Instances** — same, still open.
- **Food and snacks in a miniland** — minilands are created after start-up and never get a
life loop, so nothing that ticks per map (regeneration, digestion) runs there yet.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using NosCore.GameObject.Services.BattleService;
using NosCore.GameObject.Services.BroadcastService;
using NosCore.GameObject.Services.GroupService;
using NosCore.Packets.ServerPackets.Groups;
using NosCore.GameObject.Services.InventoryService;
using NosCore.GameObject.Services.ItemGenerationService;
using NosCore.GameObject.Services.ItemGenerationService.Item;
Expand Down Expand Up @@ -416,6 +417,24 @@ await characterEntity.SendPacketAsync(new MsgiPacket
});
}

// Alone, the official server lists nobody; in a group the players come first. Either way
// the recipient's own pets that are out follow.
public static PinitPacket GeneratePinit(this ICharacterEntity character)
{
var group = character.Group;
var entries = group is { Count: > 1 }
? group.GeneratePinit().PinitSubPackets ?? new List<PinitSubPacket?>()
: new List<PinitSubPacket?>();
if (character is PlayerComponentBundle player)
{
entries.AddRange(player.Mates.Values
.Where(s => s.IsTeamMember)
.Select(s => (PinitSubPacket?)s.GenerateSubPinit(player.AccountLanguage)));
}

return new PinitPacket { GroupSize = entries.Count, PinitSubPackets = entries };
}

public static void JoinGroup(this ICharacterEntity characterEntity, Group group)
{
characterEntity.Group = group;
Expand Down Expand Up @@ -446,7 +465,7 @@ await groupMember.SendPacketAsync(new MsgiPacket
});
}

await groupMember.SendPacketAsync(groupMember.Group.GeneratePinit());
await groupMember.SendPacketAsync(groupMember.GeneratePinit());
}

characterEntity.Group = new Group(GroupType.Group, sessionGroupFactory);
Expand Down
43 changes: 43 additions & 0 deletions src/NosCore.GameObject/Ecs/Extensions/MateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
//

using NosCore.Data.Enumerations.Character;
using NosCore.GameObject.Services.MapInstanceGenerationService;
using NosCore.GameObject.Services.MateService;
using NosCore.Packets.Enumerations;
using NosCore.Packets.ServerPackets.Entities;
using NosCore.Packets.ServerPackets.Groups;
using NosCore.Packets.ServerPackets.Mates;
using NosCore.Packets.ServerPackets.Parcel;
using NosCore.Packets.ServerPackets.Player;
using NosCore.Packets.ServerPackets.Visibility;
using NosCore.Networking;
using NosCore.Shared.Enumerations;
using System.Globalization;
using System.Threading.Tasks;

namespace NosCore.GameObject.Ecs.Extensions
{
Expand Down Expand Up @@ -136,6 +140,45 @@ public static InPacket GenerateIn(this Mate mate, RegionType language)
};
}

public static PinitSubPacket GenerateSubPinit(this Mate mate, RegionType language)
{
return new PinitSubPacket
{
VisualType = VisualType.Npc,
VisualId = mate.MateTransportId,
GroupPosition = (int)mate.MateType,
Level = mate.Level,
Name = mate.DisplayName(language),
Unknown = -1,
Unknown2 = mate.VNum
};
}

public static Task SpawnAsync(this Mate mate, PlayerComponentBundle owner, MapInstance mapInstance,
short x, short y)
{
mate.PositionX = x;
mate.PositionY = y;
var handle = mapInstance.EcsWorld.CreateMate((int)mate.MateTransportId, mate, mapInstance, x, y, 2);
mate.Entity = new MateComponentBundle(handle, mapInstance.EcsWorld);

// A hidden owner's pet would put the owner back on screen.
var spawn = mate.GenerateIn(owner.AccountLanguage);
return owner.Invisible ? owner.SendPacketAsync(spawn) : mapInstance.SendPacketAsync(spawn);
}

public static async Task DespawnAsync(this Mate mate, MapInstance mapInstance)
{
if (mate.Entity is not { } entity)
{
return;
}

await mapInstance.SendPacketAsync(mate.GenerateOut());
mapInstance.EcsWorld.DestroyEntity(entity.Handle);
mate.Entity = null;
}

public static OutPacket GenerateOut(this Mate mate)
{
return new OutPacket
Expand Down
18 changes: 10 additions & 8 deletions src/NosCore.GameObject/Ecs/Extensions/PlayerBundleExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
using NosCore.GameObject.Services.BroadcastService;
using NosCore.GameObject.Services.GroupService;
using NosCore.GameObject.Services.InventoryService;
using NosCore.GameObject.Services.MateService;
using NosCore.GameObject.Services.ItemGenerationService;
using NosCore.GameObject.Services.ItemGenerationService.Item;
using NosCore.Networking;
Expand Down Expand Up @@ -73,6 +74,10 @@ public static Task SendPacketsAsync(this PlayerComponentBundle player, IEnumerab
public static bool CanSitDown(this PlayerComponentBundle player, Instant now) =>
now - player.LastAttack >= SitLockAfterAttack;

public static IEnumerable<IPacket> GenerateMateRoster(this PlayerComponentBundle player) =>
new IPacket[] { new PclearPacket() }
.Concat(MateService.GenerateScPackets(player.Mates.Values, player.AccountLanguage));

public static Task RestAsync(this PlayerComponentBundle player)
{
if (player.IsSitting)
Expand Down Expand Up @@ -233,10 +238,10 @@ await Task.WhenAll(mapSessions.Select(async s =>

if (sessionRegistry.TryGetCharacter(s => s.VisualId == member.Item2, out var groupMember))
{
await groupMember.SendPacketAsync(group.GeneratePinit());
await groupMember.SendPacketAsync(groupMember.GeneratePinit());
}
}
await player.SendPacketAsync(group.GeneratePinit());
await player.SendPacketAsync(player.GeneratePinit());
}

await player.SendPacketAsync(new MsgiPacket
Expand Down Expand Up @@ -386,11 +391,8 @@ public static FdPacket GenerateFd(this PlayerComponentBundle player)
};
}

// Mirrors OpenNos's Character.GenerateReqInfo (Character.cs:3433). Returns the tc_info
// packet sent in response to a client req_info on a player target — the right-click
// info card showing class, level, equipped gear rare/upgrade, faction, PvP stats, etc.
// Family / talent / Act4 fields default to 0 because those subsystems aren't wired in
// NosCore yet; once they are, populate from the corresponding character state.
// The right-click info card for a player: class, level, worn gear, faction and PvP stats.
// Family, talent and Act4 fields stay 0 until those systems exist.
public static TcInfoPacket GenerateReqInfo(this PlayerComponentBundle player)
{
var inv = player.InventoryService;
Expand All @@ -411,7 +413,7 @@ public static TcInfoPacket GenerateReqInfo(this PlayerComponentBundle player)
ElementRate = fairy?.Item?.ElementRate ?? 0,
Class = player.Class,
Gender = player.Gender,
// No family system yet; OpenNos sends "{familyId} {name}({rank})" or "-1 -" when none.
// No family system yet; "-1 -" is how the card reads "no family".
Family = "-1 -",
ReputationIco = (ExtendedReputationType)GetReputationIcon(player.Reputation),
DignityIco = (CharacterDignity)GetDignityIcon(player.Dignity),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ public async Task HandleDisconnectAsync(ClientSession session)

if (session.HasPlayerEntity)
{
await session.Character.MapInstance!.SendPacketAsync(session.Character.GenerateOut());
var mapInstance = session.Character.MapInstance!;
await mapInstance.SendPacketAsync(session.Character.GenerateOut());
foreach (var mate in session.Character.Mates.Values)
{
await mate.DespawnAsync(mapInstance);
}
}
}
}
19 changes: 6 additions & 13 deletions src/NosCore.GameObject/Services/BattleService/BattleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ public async Task Hit(IAliveEntity origin, IAliveEntity target, HitArguments arg
return;
}

// Capture skills branch before damage — vanosilla / OpenNos both suppress
// normal damage and SuPacket for skills carrying a CaptureAnimal bcard.
// A fail still consumes the cooldown via ScheduleCooldownReset below.
// A capture deals no damage: CaptureService sends its own animation and outcome, and a
// failed attempt still costs the cooldown below.
if (captureService.IsCaptureSkill(skill))
{
try
Expand Down Expand Up @@ -116,11 +115,8 @@ private async Task ProcessHitAsync(IAliveEntity origin, IAliveEntity target, Ski
return;
}

// Order matters: OpenNos ships stat-to-target BEFORE the SuPacket so the
// client HUD bar settles to the new HP before the damage floater animates.
// Swapping the order makes the bar appear "frozen" even though the packet
// arrived — the client paints the floater first, re-reads its cached HP
// for the bar, then finally applies the stat update out-of-band.
// stat goes to the target before su: the client paints the damage number first and
// re-reads its cached HP for the bar, so a stat arriving later leaves the bar frozen.
await SendTargetStatAsync(target).ConfigureAwait(false);
await BroadcastHitAsync(origin, target, skill, outcome).ConfigureAwait(false);
await BroadcastTargetInfoAsync(target).ConfigureAwait(false);
Expand Down Expand Up @@ -155,11 +151,8 @@ private async Task ProcessHitAsync(IAliveEntity origin, IAliveEntity target, Ski
}
}

// The attacked character's own top-left HP/MP HUD reads off `stat`. OpenNos
// emits this unicast to just the target (`ReceiverType.OnlySomeone`) BEFORE
// the SuPacket — BattleEntity.TargetHit builds GenerateStat, broadcasts it to
// only the target, then broadcasts SuPacket to the whole map. We mirror that
// order so the client settles the bar before animating damage.
// The target's own HP/MP bars read off `stat`, which goes to the target alone before the su
// goes to the map, so the bar settles before the damage animates.
private static Task SendTargetStatAsync(IAliveEntity target)
{
if (target is PlayerComponentBundle player)
Expand Down
Loading