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 { /// /// 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. /// public static class LevelRewards { private const int TOKENS = -1; // the reference's CONSUMABLE_REWARD slot /// GiftContext.LevelUp - the client's level-up box look. 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, }; /// Tokens a level's token box pays: scales with the level so a late token level /// is not a consolation prize. 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? _pool; private static readonly Random _rng = new(); private static List Pool() { lock (_lock) { if (_pool != null) return _pool; var list = new List(); 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; } } /// A random avatar item of the rarity the player doesn't own, or null when none is left. public static PoolItem? RollClothing(int rarity, HashSet 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)]; } /// /// 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. /// public static void GrantLevelUps(ulong playerId, int fromLevel, int toLevel) { if (toLevel <= fromLevel) return; HashSet? 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}"); } } } } }