Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3c5c5d5
Add Constants WIP
mfranca0009 Feb 20, 2026
55c8d2a
Organize and clean-up old constants values WIP. Start using new serve…
mfranca0009 Feb 23, 2026
e11eec4
Continue switching hard coded constants to server constants parsed du…
mfranca0009 Feb 23, 2026
af10dca
Continue switching hard coded constants to server constants pared dur…
mfranca0009 Feb 24, 2026
3f5414d
Continue switching hard coded constants to server constants pared dur…
mfranca0009 Feb 26, 2026
f633b68
Fix last hard coded constant value in PartyManager. Add in input clea…
mfranca0009 Feb 27, 2026
24c70fa
Remove hard coded constants and utilize server constants within Inven…
mfranca0009 Feb 28, 2026
059fa69
JewelRuby
jewelpetmurumo-alt Feb 28, 2026
dff14e7
Update the constant parsing to make it more efficient by removing the…
mfranca0009 Mar 2, 2026
5d6f087
Correct some misspelt property names in ConstantsTable record to allo…
mfranca0009 Mar 2, 2026
a31f930
Add Xml.m2d constants.xml within the Server.m2d constants.xml parsing…
mfranca0009 Mar 2, 2026
d1c3b13
Fix a crash at character entering world due to JSON deserialization c…
mfranca0009 Mar 2, 2026
2139067
Merge branch 'master' into Issue317
mfranca0009 Mar 3, 2026
80ffb23
Fix changes that were deleted during merge conflict resolution. Added…
mfranca0009 Mar 3, 2026
8adfa4d
Took CodeRabbit suggestion since it pointed out unreachable branches …
mfranca0009 Mar 3, 2026
1dadbe8
Revert "JewelRuby"
jewelpetmurumo-alt Mar 3, 2026
94f6024
Reapply "JewelRuby"
jewelpetmurumo-alt Mar 3, 2026
c034216
Update RestrictedBuyData.cs
jewelpetmurumo-alt Mar 5, 2026
be65783
Merge branch 'pr/661' into fixfox
jewelpetmurumo-alt Mar 5, 2026
ba981db
Optimize the server archiving mechanism
jewelpetmurumo-alt Mar 6, 2026
18655d1
Revert "Update RestrictedBuyData.cs"
jewelpetmurumo-alt Mar 6, 2026
517fe84
Reapply "Update RestrictedBuyData.cs"
jewelpetmurumo-alt Mar 6, 2026
c62e56d
Revert "Merge branch 'pr/661' into fixfox"
jewelpetmurumo-alt Mar 6, 2026
6bf3d5e
Fix some systems in the guild
jewelpetmurumo-alt Mar 6, 2026
6ff3785
Fix damage statistics and calculation
jewelpetmurumo-alt Mar 6, 2026
b1d810c
Fixes for merging the main branch
jewelpetmurumo-alt Mar 7, 2026
6770210
Fix the pet-related system
jewelpetmurumo-alt Mar 8, 2026
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
149 changes: 141 additions & 8 deletions Maple2.Database/Storage/Game/GameStorage.Guild.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Maple2.Model.Enum;
using Maple2.Model.Game;
using Maple2.Tools.Extensions;
using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;

namespace Maple2.Database.Storage;
Expand Down Expand Up @@ -42,6 +43,140 @@ public IList<GuildMember> GetGuildMembers(IPlayerInfoProvider provider, long gui
.ToList();
}


public IList<Guild> SearchGuilds(IPlayerInfoProvider provider, string guildName = "", GuildFocus? focus = null, int limit = 50) {
IQueryable<Model.Guild> query = Context.Guild;
if (!string.IsNullOrWhiteSpace(guildName)) {
query = query.Where(guild => EF.Functions.Like(guild.Name, $"%{guildName}%"));
}
if (focus.HasValue && (int) focus.Value != 0) {
query = query.Where(guild => guild.Focus == focus.Value);
}

List<long> guildIds = query.OrderBy(guild => guild.Name)
.Take(limit)
.Select(guild => guild.Id)
.ToList();

var result = new List<Guild>();
foreach (long id in guildIds) {
Guild? guild = LoadGuild(id, string.Empty);
if (guild == null) {
continue;
}

foreach (GuildMember member in GetGuildMembers(provider, id)) {
guild.Members.TryAdd(member.CharacterId, member);
guild.AchievementInfo += member.Info.AchievementInfo;
}
result.Add(guild);
}

return result;
}

public GuildApplication? CreateGuildApplication(IPlayerInfoProvider provider, long guildId, long applicantId) {
Guild? guild = LoadGuild(guildId, string.Empty);
PlayerInfo? applicant = provider.GetPlayerInfo(applicantId);
if (guild == null || applicant == null) {
return null;
}

if (Context.GuildApplication.Any(app => app.GuildId == guildId && app.ApplicantId == applicantId)) {
Model.GuildApplication existing = Context.GuildApplication.First(app => app.GuildId == guildId && app.ApplicantId == applicantId);
return new GuildApplication {
Id = existing.Id,
Guild = guild,
Applicant = applicant,
CreationTime = existing.CreationTime.ToEpochSeconds(),
};
}

var app = new Model.GuildApplication {
GuildId = guildId,
ApplicantId = applicantId,
};
Context.GuildApplication.Add(app);
if (!SaveChanges()) {
Comment on lines +85 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make guild-application creation atomic.

The Any(...) check at Line 85 and the later Add(...) at Line 99 are separate round trips. Concurrent apply requests can both observe “no row” here; without a uniqueness guarantee one inserts a duplicate, and with one the loser still falls back to the generic error path instead of returning the existing application. Collapse this into one atomic path so repeated applies are idempotent.

As per coding guidelines, "Use locks for concurrent database operations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Maple2.Database/Storage/Game/GameStorage.Guild.cs` around lines 85 - 100, The
current two-step check-then-insert using Context.GuildApplication.Any(...) and
Context.GuildApplication.Add(...) is racy; make creation atomic by performing a
single transactional upsert or guarded insert and returning the existing row on
conflict. Modify the method handling guild applications so it either (a) wraps
the add+save in a transaction/lock (e.g., a DB transaction or a process-level
lock) and queries again only if SaveChanges() fails, or (b) relies on a unique
DB constraint on (GuildId, ApplicantId) and catches the insert exception from
SaveChanges()/DbUpdateException to fetch and return the existing
Model.GuildApplication (the existing variable) instead of returning a generic
error. Ensure you update the code paths around Context.GuildApplication, the
created Model.GuildApplication instance, and the SaveChanges() handling to
return a consistent GuildApplication DTO when a duplicate already exists.

return null;
}

return new GuildApplication {
Id = app.Id,
Guild = guild,
Applicant = applicant,
CreationTime = app.CreationTime.ToEpochSeconds(),
};
}

public GuildApplication? GetGuildApplication(IPlayerInfoProvider provider, long applicationId) {
Model.GuildApplication? app = Context.GuildApplication.FirstOrDefault(app => app.Id == applicationId);
if (app == null) {
return null;
}

Guild? guild = LoadGuild(app.GuildId, string.Empty);
PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId);
if (guild == null || applicant == null) {
return null;
}

return new GuildApplication {
Id = app.Id,
Guild = guild,
Applicant = applicant,
CreationTime = app.CreationTime.ToEpochSeconds(),
};
}

public IList<GuildApplication> GetGuildApplications(IPlayerInfoProvider provider, long guildId) {
List<Model.GuildApplication> applications = Context.GuildApplication.Where(app => app.GuildId == guildId)
.OrderByDescending(app => app.CreationTime)
.ToList();

return applications
.Select(app => {
Guild? guild = LoadGuild(app.GuildId, string.Empty);
PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId);
if (guild == null || applicant == null) {
return null;
}

return new GuildApplication {
Id = app.Id,
Guild = guild,
Applicant = applicant,
CreationTime = app.CreationTime.ToEpochSeconds(),
};
})
.WhereNotNull()
.ToList();
}

public IList<GuildApplication> GetGuildApplicationsByApplicant(IPlayerInfoProvider provider, long applicantId) {
List<Model.GuildApplication> applications = Context.GuildApplication.Where(app => app.ApplicantId == applicantId)
.OrderByDescending(app => app.CreationTime)
.ToList();

return applications
.Select(app => {
Guild? guild = LoadGuild(app.GuildId, string.Empty);
PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId);
if (guild == null || applicant == null) {
return null;
}

return new GuildApplication {
Id = app.Id,
Guild = guild,
Applicant = applicant,
CreationTime = app.CreationTime.ToEpochSeconds(),
};
})
.WhereNotNull()
.ToList();
Comment on lines +156 to +177
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ListAppliedGuilds will always serialize an empty member count from this data shape.

GetGuildApplicationsByApplicant(...) builds each GuildApplication.Guild via LoadGuild(...), but that helper does not load members. GuildPacket.ListAppliedGuilds(...) later writes application.Guild.Members.Count, so these results will report 0 members even for populated guilds. Populate members and aggregate AchievementInfo here the same way SearchGuilds(...) does, or reuse a shared helper that returns a fully hydrated guild.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Maple2.Database/Storage/Game/GameStorage.Guild.cs` around lines 156 - 177,
GetGuildApplicationsByApplicant builds GuildApplication.Guild via LoadGuild
which does not populate Members or aggregate AchievementInfo, causing
GuildPacket.ListAppliedGuilds to report Members.Count == 0; change
GetGuildApplicationsByApplicant to return fully-hydrated guilds the same way
SearchGuilds does (populate guild.Members and aggregate AchievementInfo) or call
the shared helper used by SearchGuilds that returns a complete guild object,
ensuring GuildApplication.Guild has its Members populated before mapping to
GuildApplication.

}

public Guild? CreateGuild(string name, long leaderId) {
BeginTransaction();

Expand Down Expand Up @@ -155,20 +290,18 @@ public bool DeleteGuildApplications(long characterId) {
public bool SaveGuildMembers(long guildId, ICollection<GuildMember> members) {
Dictionary<long, GuildMember> saveMembers = members
.ToDictionary(member => member.CharacterId, member => member);
IEnumerable<Model.GuildMember> existingMembers = Context.GuildMember
HashSet<long> existingMembers = Context.GuildMember
.Where(member => member.GuildId == guildId)
.Select(member => new Model.GuildMember {
CharacterId = member.CharacterId,
});
.Select(member => member.CharacterId)
.ToHashSet();

foreach (Model.GuildMember member in existingMembers) {
if (saveMembers.Remove(member.CharacterId, out GuildMember? gameMember)) {
foreach ((long characterId, GuildMember gameMember) in saveMembers) {
if (existingMembers.Contains(characterId)) {
Context.GuildMember.Update(gameMember);
} else {
Context.GuildMember.Remove(member);
Context.GuildMember.Add(gameMember);
}
}
Context.GuildMember.AddRange(saveMembers.Values.Select<GuildMember, Model.GuildMember>(member => member));

return SaveChanges();
}
Expand Down
6 changes: 3 additions & 3 deletions Maple2.Database/Storage/Game/GameStorage.User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -582,9 +582,9 @@ public Account CreateAccount(Account account, string password) {
model.Password = BCrypt.Net.BCrypt.HashPassword(password, 13);
#if DEBUG
model.Currency = new AccountCurrency {
Meret = 9_999_999,
Meret = 1_000_00,
};
model.Permissions = AdminPermissions.Admin.ToString();
// model.Permissions = AdminPermissions.Admin.ToString();
#endif
Context.Account.Add(model);
Context.SaveChanges(); // Exception if failed.
Expand All @@ -609,7 +609,7 @@ public Account CreateAccount(Account account, string password) {
model.Channel = -1;
#if DEBUG
model.Currency = new CharacterCurrency {
Meso = 999999999,
Meso = 500000,
};
#endif
Context.Character.Add(model);
Expand Down
18 changes: 1 addition & 17 deletions Maple2.Model/Enum/GameEventUserValueType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ public enum GameEventUserValueType {
// Attendance Event
AttendanceActive = 100, //?? maybe. String is "True"
AttendanceCompletedTimestamp = 101,
AttendanceRewardsClaimed = 102, // Also used for Cash Attendance and DT Attendance
AttendanceRewardsClaimed = 102,
AttendanceEarlyParticipationRemaining = 103,
AttendanceNear = 105,
AttendanceAccumulatedTime = 106,

// ReturnUser
ReturnUser = 320, // IsReturnUser

// DTReward
DTRewardStartTime = 700, // start time
DTRewardCurrentTime = 701, // current item accumulated time
Expand All @@ -29,22 +25,10 @@ public enum GameEventUserValueType {
GalleryCardFlipCount = 1600,
GalleryClaimReward = 1601,

// Snowman Event
SnowflakeCount = 1700,
DailyCompleteCount = 1701,
AccumCompleteCount = 1702,
AccumCompleteRewardReceived = 1703,

// Rock Paper Scissors Event
RPSDailyMatches = 1800,
RPSRewardsClaimed = 1801,

// Couple Dance
CoupleDanceBannerOpen = 2100,
CoupleDanceRewardState = 2101, // completed/bonus/received flags

CollectItemGroup = 2200, // Meta Badge event. Serves as a flag for tiers

// Bingo - TODO: These are not the actual confirmed values. Just using it as a way to store this data for now.
BingoUid = 4000,
BingoRewardsClaimed = 4001,
Expand Down
19 changes: 19 additions & 0 deletions Maple2.Model/Game/Dungeon/DungeonUserRecord.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using Maple2.Model.Enum;
using Maple2.PacketLib.Tools;
using Maple2.Tools.Extensions;

namespace Maple2.Model.Game.Dungeon;

Expand Down Expand Up @@ -86,5 +87,23 @@ public void WriteTo(IByteWriter writer) {
writer.WriteBool(item.Unknown2);
writer.WriteBool(item.Unknown3);
}

// Party meter / performance details.
// Keep this block after the reward data to preserve the packet layout that already works
// for the personal reward/result UI, while still exposing dungeon accumulation records
// to clients that read the extended statistics section.
writer.WriteInt(AccumulationRecords.Count);
foreach ((DungeonAccumulationRecordType type, int value) in AccumulationRecords.OrderBy(entry => (int) entry.Key)) {
writer.Write<DungeonAccumulationRecordType>(type);
writer.WriteInt(value);
}

writer.WriteInt(Missions.Count);
foreach (DungeonMission mission in Missions.Values.OrderBy(mission => mission.Id)) {
writer.WriteClass<DungeonMission>(mission);
}

writer.Write<DungeonBonusFlag>(Flag);
writer.WriteInt(Round);
}
}
22 changes: 13 additions & 9 deletions Maple2.Model/Game/Market/SoldUgcMarketItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,25 @@ public class SoldUgcMarketItem : IByteSerializable {
public required string Name { get; init; }
public long SoldTime { get; init; }
public long AccountId { get; init; }

public int Count { get; init; } = 1;

public void WriteTo(IByteWriter writer) {
writer.WriteLong(Id);
writer.WriteLong();
writer.WriteLong();
writer.WriteUnicodeString(Name);
writer.WriteInt();
writer.WriteInt();
writer.WriteLong();
writer.WriteLong();
writer.WriteUnicodeString();
writer.WriteUnicodeString();
writer.WriteInt();

writer.WriteInt(Count);
writer.WriteInt();

writer.WriteLong();
writer.WriteLong();
writer.WriteUnicodeString();
writer.WriteUnicodeString();
writer.WriteInt();

writer.WriteLong(Price);
writer.WriteLong(SoldTime);
writer.WriteLong(Profit);
}
}

8 changes: 5 additions & 3 deletions Maple2.Model/Game/Shop/RestrictedBuyData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ public readonly struct BuyTimeOfDay {
public int EndTimeOfDay { get; } // time end in seconds. ex 10600 = 2:56 AM

[JsonConstructor]
public BuyTimeOfDay(int startTime, int endTime) {
StartTimeOfDay = startTime;
EndTimeOfDay = endTime;
// System.Text.Json requires every parameter on the [JsonConstructor] to bind to a
// property/field on the type. Parameter names must match property names (case-insensitive).
public BuyTimeOfDay(int startTimeOfDay, int endTimeOfDay) {
StartTimeOfDay = startTimeOfDay;
EndTimeOfDay = endTimeOfDay;
}

public BuyTimeOfDay Clone() {
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Model/Metadata/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public static class Constant {

public const bool DebugTriggers = false; // Set to true to enable debug triggers. (It'll write triggers to files and load triggers from files instead of DB)

public const bool AllowUnicodeInNames = false; // Allow Unicode characters in character and guild names
public const bool AllowUnicodeInNames = true; // Allow Unicode characters in character and guild names

public static IReadOnlyDictionary<string, int> ContentRewards { get; } = new Dictionary<string, int> {
{ "miniGame", 1005 },
Expand Down
5 changes: 2 additions & 3 deletions Maple2.Server.Core/Formulas/BonusAttack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ public static double Coefficient(int rightHandRarity, int leftHandRarity, JobCod
}

double weaponBonusAttackCoefficient = RarityMultiplier(rightHandRarity);
if (leftHandRarity == 0) {
return weaponBonusAttackCoefficient;
if (leftHandRarity > 0) {
weaponBonusAttackCoefficient = 0.5 * (weaponBonusAttackCoefficient + RarityMultiplier(leftHandRarity));
}

weaponBonusAttackCoefficient = 0.5 * (weaponBonusAttackCoefficient + RarityMultiplier(leftHandRarity));
return 4.96 * weaponBonusAttackCoefficient * JobBonusMultiplier(jobCode);
}

Expand Down
8 changes: 2 additions & 6 deletions Maple2.Server.Core/Network/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ public abstract class Session : IDisposable {

private bool disposed;
private int disconnecting; // 0 = not disconnecting, 1 = disconnect in progress/already triggered (reentrancy guard)
private volatile bool sendFailed; // set on first SendRaw failure to stop send queue drain
private readonly uint siv;
private readonly uint riv;

Expand Down Expand Up @@ -336,7 +335,6 @@ private void SendRaw(ByteWriter packet) {
// Use async write with timeout to prevent indefinite blocking
Task writeTask = networkStream.WriteAsync(packet.Buffer, 0, packet.Length);
if (!writeTask.Wait(SEND_TIMEOUT_MS)) {
sendFailed = true;
Logger.Warning("SendRaw timeout after {Timeout}ms, disconnecting account={AccountId} char={CharacterId}",
SEND_TIMEOUT_MS, AccountId, CharacterId);

Expand All @@ -358,12 +356,10 @@ private void SendRaw(ByteWriter packet) {
throw writeTask.Exception?.GetBaseException() ?? new Exception("Write task faulted");
}
} catch (Exception ex) when (ex.InnerException is IOException or SocketException || ex is IOException or SocketException) {
// Connection was closed by the client or is no longer valid
sendFailed = true;
// Expected when client closes the connection (e.g., during migration)
Logger.Debug("SendRaw connection closed account={AccountId} char={CharacterId}", AccountId, CharacterId);
Disconnect();
} catch (Exception ex) {
sendFailed = true;
Logger.Warning(ex, "[LIFECYCLE] SendRaw write failed account={AccountId} char={CharacterId}", AccountId, CharacterId);
Disconnect();
}
Expand All @@ -372,7 +368,7 @@ private void SendRaw(ByteWriter packet) {
private void SendWorker() {
try {
foreach ((byte[] packet, int length) in sendQueue.GetConsumingEnumerable()) {
if (disposed || sendFailed) break;
if (disposed) break;

// Encrypt outside lock, then send with timeout
PoolByteWriter encryptedPacket;
Expand Down
Loading