mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
e33145307f
Most pushes were still shaped for the 2020 watch, so the 2023 client parsed them as empty or dropped them without any error. - RelationshipChanged carries the relationship row, to both players, on friend request / accept / remove / add and on favorite / mute / ignore. Friend requests and accepts also land in the notifications inbox. - ModerationKick uses the client's flat moderation block, with the reason in VoteKickReason (the only field the dialog shows). - Bans, account deletion, password resets and second-session takeover are a Logout with a numeric reason instead of a kick to the dorm; the old string reason failed the client's parse. - SubscribeToPlayers is honoured: each connection joins a presence group per subscribed player, and presence (only presence) reaches subscribers as well as friends. The hub allows 180 s of client silence, since the client reconnects at most 3 times per login. - Admin broadcast sends its text as a coach message and can start the client's own maintenance countdown; ServerMaintenance only reads StartsInMinutes. - ConsumableMapping, StorefrontBalance, gift, player event, level-up, RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's DTOs. "Join rejected" is the client's RequestGameInvite message. Contract in docs/recroom-2023-notifications.md; push frames are checked by NotificationContractTests through a capturing hub context.
112 lines
4.9 KiB
C#
112 lines
4.9 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using DorkNet.Models.Notification;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
|
|
namespace DorkNet.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Awards XP, computes level transitions, and adjusts currency
|
|
/// balances. Centralising these mutations behind one service avoids
|
|
/// scattered <c>db.Players.Find()</c>; <c>p.XP += n</c>; calls and
|
|
/// gives us a single place to adjust the reward curve.
|
|
///
|
|
/// Curve: <c>level = floor(sqrt(xp / 100)) + 1</c> — i.e. each level
|
|
/// costs <c>100 * (level^2 - (level-1)^2)</c> XP, scaling
|
|
/// quadratically. Level 1 starts at 0 XP, Level 2 at 100, Level 3 at
|
|
/// 400, Level 5 at 1600, Level 10 at 8100. Loose match for the
|
|
/// 2020 client's expected curve.
|
|
/// </summary>
|
|
public class LevelService(DorkNetDbContext db, NotificationService notifications)
|
|
{
|
|
public const int FirstLoginXp = 50;
|
|
public const int InventionSavedXp = 25;
|
|
public const int CheerReceivedXp = 10;
|
|
public const int RoomVisitXp = 5;
|
|
|
|
/// <summary>Add <paramref name="amount"/> XP to the player. Returns
|
|
/// the new (level, xp) pair. Level transitions push a
|
|
/// <c>SubscriptionUpdateProfile</c> notification with the new
|
|
/// level. Idempotent at the row level — concurrent calls might
|
|
/// double-grant if both load the same XP value, but the curve
|
|
/// tolerates that.</summary>
|
|
public async Task<(int level, int xp)> AwardXpAsync(long playerId, int amount, string reason)
|
|
{
|
|
if (amount <= 0) return (1, 0);
|
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
|
if (player is null) return (1, 0);
|
|
|
|
var oldLevel = player.Level;
|
|
player.XP += amount;
|
|
player.Level = LevelForXp(player.XP);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
|
|
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
|
|
// api/players/v2/progression returns. Without it the watch kept the
|
|
// level and XP it loaded at login until the next relog.
|
|
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
|
|
new { PlayerId = (int)playerId, player.Level, player.XP });
|
|
|
|
if (player.Level > oldLevel)
|
|
{
|
|
// Level-up grants a small currency reward. The wallet UI
|
|
// refreshes off the StorefrontBalanceUpdate that
|
|
// GrantCurrencyAsync already pushes — do NOT also push
|
|
// SubscriptionUpdateProfile ("AccountUpdate") with a
|
|
// {Reason,Level} blob: the 2023 client deserialises that
|
|
// channel as its Account DTO, so a non-account payload
|
|
// decodes to a blank account (accountId 0) and logs
|
|
// "Orphan player account (0) found" (audited 2026-07-12).
|
|
await GrantCurrencyAsync(playerId, currencyType: 2,
|
|
amount: 100 * (player.Level - oldLevel),
|
|
reason: $"levelup:{oldLevel}->{player.Level}");
|
|
}
|
|
|
|
return (player.Level, player.XP);
|
|
}
|
|
|
|
/// <summary>Grant or deduct currency. <paramref name="amount"/>
|
|
/// can be negative for deductions (e.g. spending in storefronts).
|
|
/// Pushes <c>StorefrontBalanceUpdated</c> when the balance moves.</summary>
|
|
public async Task<long> GrantCurrencyAsync(
|
|
long playerId, int currencyType, long amount, string reason)
|
|
{
|
|
if (amount == 0) return await GetBalanceAsync(playerId, currencyType);
|
|
var row = await db.CurrencyBalances.FirstOrDefaultAsync(
|
|
c => c.PlayerId == playerId && c.CurrencyType == currencyType);
|
|
if (row is null)
|
|
{
|
|
row = new CurrencyBalanceEntity
|
|
{
|
|
PlayerId = playerId,
|
|
CurrencyType = currencyType,
|
|
Balance = 0,
|
|
};
|
|
db.CurrencyBalances.Add(row);
|
|
}
|
|
row.Balance = Math.Max(0, row.Balance + amount);
|
|
row.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
|
|
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
|
|
// CurrencyType, Platform}; the client sets that (CurrencyType,
|
|
// Platform) bucket to Balance. Platform 0 matches every balance REST
|
|
// response, so the buckets line up.
|
|
await notifications.NotifyAsync(playerId,
|
|
PushNotificationId.StorefrontBalanceUpdate,
|
|
new { row.Balance, CurrencyType = currencyType, Platform = 0 });
|
|
return row.Balance;
|
|
}
|
|
|
|
public async Task<long> GetBalanceAsync(long playerId, int currencyType) =>
|
|
await db.CurrencyBalances
|
|
.Where(c => c.PlayerId == playerId && c.CurrencyType == currencyType)
|
|
.Select(c => c.Balance)
|
|
.FirstOrDefaultAsync();
|
|
|
|
/// <summary>Inverse of the level→xp curve. <c>floor(sqrt(xp/100)) + 1</c>.</summary>
|
|
public static int LevelForXp(int xp) =>
|
|
xp <= 0 ? 1 : (int)Math.Floor(Math.Sqrt(xp / 100.0)) + 1;
|
|
}
|