Files
2026-10-14 06:59:51 +02:00

206 lines
10 KiB
C#

using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// What REACHING each level pays, and the grant of it. Ported from the reference's
/// progression-db.ts LEVEL_REWARDS + econ.app.ts grantLevelUpGifts: one gift box per level
/// crossed, announced like any other unasked-for gift. Nothing here was doing this before -
/// a level-up moved the crown and paid nothing.
///
/// The table is Rec Room's published level-reward ladder, in the star ratings it uses
/// (2-Star = rarity 10, 3-Star 20, 4-Star 30, 5-Star 50). The reference pays a CONSUMABLE on
/// the early levels the table marks that way; consumables are a static catalog on this server
/// (nothing is unlocked per player), so those levels pay a token box instead - a real reward
/// rather than a cosmetic one. Clothing levels roll a random avatar item of the level's
/// rarity from the general store (3.json, the only catalog with a pool at every tier) that
/// the player does NOT already own; when that pool is exhausted the level pays tokens too.
/// </summary>
public static class LevelRewards
{
private const int TOKENS = -1; // the reference's CONSUMABLE_REWARD slot
/// <summary>GiftContext.LevelUp - the client's level-up box look.</summary>
public const int GiftContextLevelUp = 100;
// Indexed BY level; level 0 is unreachable.
private static readonly int[] LEVEL_REWARDS =
{
0,
// 1-10: token boxes interleaved with the first clothing drops.
TOKENS, 10, TOKENS, 10, TOKENS,
TOKENS, TOKENS, 10, TOKENS, 10,
// 11-20: 2-Star clothing all the way.
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
// 21-30: 2-Star alternating with 3-Star.
10, 20, 10, 20, 10, 20, 10, 20, 10, 20,
// 31-40: 3-Star with a 4-Star every few levels.
30, 20, 20, 20, 30, 20, 20, 20, 20, 30,
// 41-50: 4-Star to the top, then the game's only 5-Star.
30, 30, 30, 30, 30, 30, 30, 30, 30, 50,
};
/// <summary>Tokens a level's token box pays: scales with the level so a late token level
/// is not a consolation prize.</summary>
public static long TokensForLevel(int level) => level switch
{
<= 5 => 50,
<= 10 => 100,
<= 20 => 150,
<= 30 => 200,
<= 40 => 300,
< 50 => 500,
_ => 1000
};
public static int RewardFor(int level) =>
level > 0 && level < LEVEL_REWARDS.Length ? LEVEL_REWARDS[level] : (level >= LEVEL_REWARDS.Length ? 30 : 0);
// ---- The roll pool: the general store's avatar items, by rarity --------------------------
public sealed class PoolItem
{
public string AvatarItemDesc = "";
public string FriendlyName = "";
public int Rarity;
public int AvatarItemType;
public int GiftDropId;
}
private static readonly object _lock = new();
private static List<PoolItem>? _pool;
private static readonly Random _rng = new();
private static List<PoolItem> Pool()
{
lock (_lock)
{
if (_pool != null) return _pool;
var list = new List<PoolItem>();
try
{
string file = System.IO.Path.Combine(StoreCatalog.StoreDirectory, "3.json");
if (System.IO.File.Exists(file))
{
var root = JObject.Parse(System.IO.File.ReadAllText(file));
foreach (var it in root["StoreItems"] as JArray ?? new JArray())
{
var gd = it["GiftDrop"] as JObject;
if (gd == null) continue;
if ((bool?)gd["IsQuery"] == true) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
int rarity = (int?)gd["Rarity"] ?? 0;
if (rarity <= 0) continue;
list.Add(new PoolItem
{
AvatarItemDesc = desc,
FriendlyName = (string?)gd["FriendlyName"] ?? "",
Rarity = rarity,
AvatarItemType = (int?)gd["AvatarItemType"] ?? 0,
GiftDropId = (int?)gd["GiftDropId"] ?? 0
});
}
}
}
catch (Exception ex) { Console.WriteLine($"[levelrewards] could not read the roll pool: {ex.Message}"); }
Console.WriteLine($"[levelrewards] roll pool: {list.Count} avatar items " +
$"({string.Join(", ", list.GroupBy(p => p.Rarity).OrderBy(g => g.Key).Select(g => $"{g.Key}:{g.Count()}"))})");
_pool = list;
return _pool;
}
}
/// <summary>A random avatar item of the rarity the player doesn't own, or null when none is left.</summary>
public static PoolItem? RollClothing(int rarity, HashSet<string> ownedKeys)
{
var candidates = Pool().Where(p => p.Rarity == rarity
&& !ownedKeys.Contains(PlayerDB.NormalizeAvatarItemDesc(p.AvatarItemDesc))).ToList();
if (candidates.Count == 0) return null;
lock (_lock) return candidates[_rng.Next(candidates.Count)];
}
/// <summary>
/// Grant the reward for every level in (fromLevel, toLevel]. Best-effort per level: the XP
/// and the level are already stored, so a failed roll costs a prize, not the level.
/// </summary>
public static void GrantLevelUps(ulong playerId, int fromLevel, int toLevel)
{
if (toLevel <= fromLevel) return;
HashSet<string>? owned = null;
for (int level = fromLevel + 1; level <= toLevel; level++)
{
try
{
int reward = RewardFor(level);
if (reward == 0) continue;
// The box the real game hands over: the LevelUp box (GiftContext 100), named
// "Level N Box", with "Congrats on getting to level N!" written on it.
string message = $"Congrats on getting to level {level}!";
PendingGiftBox box;
PoolItem? item = null;
// Items only when the item tables are switched on (their bundles are missing
// from the CDN, so they render blank); otherwise every level pays tokens.
if (reward > 0 && ConfigCampus.RewardItemsEnabled)
{
owned ??= PlayerDB.GetOwnedAvatarItemKeys(playerId);
item = RollClothing(reward, owned);
if (item != null) owned.Add(PlayerDB.NormalizeAvatarItemDesc(item.AvatarItemDesc));
else Console.WriteLine($"[levelrewards] {playerId} owns every rarity-{reward} item - level {level} pays tokens");
}
if (item != null)
{
box = new PendingGiftBox
{
GiftContext = GiftContextLevelUp,
Message = message,
FromAccountId = 0,
AvatarItemDesc = item.AvatarItemDesc,
AvatarItemType = item.AvatarItemType,
FriendlyName = item.FriendlyName,
GiftDropId = item.GiftDropId,
Rarity = item.Rarity
};
}
else
{
long tokens = TokensForLevel(level);
box = new PendingGiftBox
{
GiftContext = GiftContextLevelUp,
Message = message,
FromAccountId = 0,
FriendlyName = $"Level {level} Box", // the box's name, as the real game labels it
// Empty = the client's own progression icon (see
// ConfigCampus.UseClientProgressionIcons).
ThumbnailImageName = ConfigCampus.UseClientProgressionIcons ? "" : QuestRewards.TokenThumbnail,
CurrencyType = (int)CurrencyType.RecCenterTokens,
Currency = tokens,
Rarity = QuestRewards.TokenRarity(tokens),
PayOnOpen = true
};
}
if (!PlayerDB.AddPendingGiftBoxFull(playerId, box))
{
Console.WriteLine($"[levelrewards] {playerId}: could not store the level {level} box");
continue;
}
GiftBoxes.Push(playerId, box, GiftBoxes.FrameReceivedImmediate, $"level {level} gift");
Console.WriteLine($"[levelrewards] {playerId} reached level {level}: " +
(item != null ? $"'{item.FriendlyName}' (rarity {item.Rarity})" : $"{box.Currency} tokens") +
$" in box {box.NumericId}");
}
catch (Exception ex)
{
Console.WriteLine($"[levelrewards] {playerId} level {level} grant failed: {ex.Message}");
}
}
}
}
}