Files
DorkNet/DorkNet.Server/Services/LevelService.cs
T
Alexa e33145307f fix: notification pushes now match what the 2023 client reads
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.
2026-10-24 19:50:05 +03:00

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;
}