using Campus_live_rewrite.api.dbs;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
///
/// The end-of-game / quest reward pools, loaded once from Data/APIS/quest-rewards.json.
///
/// The file is keyed by game-mode name (Dodgeball, Quest_Goblin_S, Soccer, ...); each value
/// is a list of reward entries a play session can roll. Every entry carries a GiftContext
/// (the "Context" field) - the same id the client sends to POST /api/gamerewards/v1/request -
/// so a reward is picked by matching the requested GiftContext, falling back to the mode name.
///
/// Content only: the entry describes WHAT to grant (avatar item / consumable / equipment +
/// rarity + XP). The actual grant is a gift box (PlayerDB.AddPendingGiftBoxFull), which also
/// puts the item in the wardrobe, plus any XP.
///
public static class QuestRewards
{
public sealed class Reward
{
public int Id;
public string? AvatarItemDesc;
public string? ConsumableItemDesc;
public string? EquipmentPrefabName;
public string? EquipmentModificationGuid;
public int CurrencyType;
public long Currency;
public int Xp;
public int Level;
public int GiftRarity;
public string? Message;
public int Context;
public string Mode = "";
/// The store row's picture for a Watch-clothing drop; sent only when that file exists here.
public string? Thumbnail;
}
// ---- Watch-store clothing offered as a reward -----------------------------------------------
// Rarity 10/20/30 = 2/3/4 stars (same scale as TokenRarity). Cached against the served JSON string.
private static string? _wcJson;
private static List _wc = new();
private static List WatchClothing()
{
string? json = Campus_live_rewrite.api.server.StoreServe.ServedJson(Campus_live_rewrite.api.server.StoreGenerator.WatchStorefront);
lock (_lock)
{
if (json != null && !ReferenceEquals(json, _wcJson))
{
var list = new List();
foreach (var it in (Newtonsoft.Json.Linq.JObject.Parse(json)["StoreItems"] as Newtonsoft.Json.Linq.JArray) ?? new Newtonsoft.Json.Linq.JArray())
{
if (it is not Newtonsoft.Json.Linq.JObject row || row["GiftDrop"] is not Newtonsoft.Json.Linq.JObject gd) continue;
int rar = (int?)gd["Rarity"] ?? -1;
if (rar != 10 && rar != 20 && rar != 30) continue;
if (string.IsNullOrWhiteSpace((string?)gd["AvatarItemDesc"])) continue;
if (!string.IsNullOrWhiteSpace((string?)gd["CustomAvatarItemId"]) || !string.IsNullOrWhiteSpace((string?)gd["EquipmentPrefabName"])
|| !string.IsNullOrWhiteSpace((string?)gd["ConsumableItemDesc"])) continue;
if (((int?)gd["GiftDropId"] ?? 0) <= 0) continue;
list.Add(row);
}
_wc = list;
_wcJson = json;
Console.WriteLine($"[questrewards] {list.Count} Watch-store clothing rows (2-4 stars) can be offered as rewards");
}
return _wc;
}
}
///
/// Up to distinct Watch-store clothing rewards (2-4 stars) the player does
/// not already own. Fewer (or none) when they own nearly everything - the caller fills with tokens.
///
public static List WatchClothingDrops(int giftContext, int count, HashSet? ownedKeys)
{
var result = new List();
if (count <= 0) return result;
List pool;
lock (_lock) pool = WatchClothing().ToList();
var seen = new HashSet();
int guard = 0;
while (result.Count < count && pool.Count > 0 && guard++ < 400)
{
Newtonsoft.Json.Linq.JObject row;
lock (_lock) row = pool[_rng.Next(pool.Count)];
var gd = (Newtonsoft.Json.Linq.JObject)row["GiftDrop"]!;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
string key = PlayerDB.NormalizeAvatarItemDesc(desc);
if (key.Length == 0 || !seen.Add(key)) continue;
if (ownedKeys != null && ownedKeys.Contains(key)) continue;
result.Add(new Reward
{
Id = (int?)gd["GiftDropId"] ?? 0,
AvatarItemDesc = desc,
GiftRarity = (int?)gd["Rarity"] ?? 10,
Message = (string?)gd["FriendlyName"],
Xp = Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
Context = giftContext != 0 ? giftContext : GiftContextGameRewards,
Mode = "WatchClothing",
Thumbnail = (string?)gd["ThumbnailImageName"]
});
}
return result;
}
// GiftContext.GameRewards (50) is the XP box; 51 is its TOKENS variant - "for when a
// reward pays currency instead of XP" (reference econ.app.ts). The client picks the box
// art and the open sound off the context, so a token drop filed under 50 played the
// plain XP box; that was the "wrong box sound" on token rewards.
public const int GiftContextGameRewards = 50;
public const int GiftContextGameRewardTokens = 51;
// The picture a token drop / token box names. Empty by default (ConfigCampus.UseClientCurrencyImages):
// the reference names none, and the client then draws its OWN token art - the picker card's
// model has a built-in CurrencyImage. Naming our campus_tokens.png overrode that with a flat
// custom PNG. (The old "blank without it" note dates from when token drops carried Currency 0.)
public static string TokenThumbnail => ConfigCampus.UseClientCurrencyImages ? "" : ConfigCampus.LevelRewardImage;
/// The box tier (star rarity) a token amount is presented at: 25 = 2-star ... 300 = 5-star.
public static int TokenRarity(long amount) => amount switch
{
< 50 => 10, // 2-star box
< 150 => 20, // 3-star
< 500 => 30, // 4-star
_ => 50 // 5-star: 500+
};
public static bool IsTokens(Reward r) => r.Currency > 0 && r.CurrencyType != 0 && !IsItem(r);
private static readonly object _lock = new();
private static readonly Dictionary> _byMode =
new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary> _byContext = new();
private static readonly List _all = new();
private static readonly Random _rng = new();
public static bool Loaded { get; private set; }
private static readonly string File1 =
System.IO.Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "quest-rewards.json");
public static void Load()
{
try
{
if (!System.IO.File.Exists(File1))
{
Console.WriteLine($"[questrewards] {File1} not found - no game rewards loaded");
return;
}
var root = JObject.Parse(System.IO.File.ReadAllText(File1));
int count = 0;
foreach (var modeProp in root.Properties())
{
if (modeProp.Value is not JArray arr) continue;
var list = new List();
foreach (var e in arr.OfType())
{
var r = new Reward
{
Id = (int?)e["Id"] ?? 0,
AvatarItemDesc = (string?)e["AvatarItemDesc"],
ConsumableItemDesc = (string?)e["ConsumableItemDesc"],
EquipmentPrefabName = (string?)e["EquipmentPrefabName"],
EquipmentModificationGuid = (string?)e["EquipmentModificationGuid"],
CurrencyType = (int?)e["CurrencyType"] ?? 0,
Currency = (long?)e["Currency"] ?? 0,
Xp = (int?)e["Xp"] ?? 0,
Level = (int?)e["Level"] ?? 0,
GiftRarity = (int?)e["GiftRarity"] ?? 0,
Message = (string?)e["Message"],
Context = (int?)e["Context"] ?? 0,
Mode = modeProp.Name
};
list.Add(r);
_all.Add(r);
if (r.Context != 0)
{
if (!_byContext.TryGetValue(r.Context, out var cl))
_byContext[r.Context] = cl = new List();
cl.Add(r);
}
count++;
}
_byMode[modeProp.Name] = list;
}
Loaded = true;
Console.WriteLine($"[questrewards] loaded {count} reward entries across {_byMode.Count} modes " +
$"({_byContext.Count} distinct gift contexts)");
}
catch (Exception ex)
{
Console.WriteLine($"[questrewards] failed to load: {ex.Message}");
}
}
private static readonly Dictionary _lastGift = new();
///
/// At-most-once-per-cooldown gate for delivering a game-reward gift box, so a client that
/// fires the reward request repeatedly cannot flood the gift inbox. Returns true (and marks
/// the clock) when a box may be delivered now.
///
public static bool TryClaimGiftCooldown(ulong playerId)
{
int cd = ConfigCampus.GameRewardGiftCooldownSeconds;
if (cd <= 0) return true;
var now = DateTime.UtcNow;
lock (_lock)
{
if (_lastGift.TryGetValue(playerId, out var last) && (now - last).TotalSeconds < cd)
return false;
_lastGift[playerId] = now;
return true;
}
}
///
/// Roll up to DISTINCT rewards (by Id) for the three-choice reward
/// selection. Draws from the GiftContext pool, then the room's mode pool, then the whole
/// catalog; if fewer than n distinct entries exist it pads with repeats so the client always
/// gets a full set. Empty only when nothing is loaded at all.
///
// What a DUPLICATE item is worth when it is offered as tokens instead. Rec Room's own box
// converts an item you already own into tokens; the client renders such a drop from its
// Currency/CurrencyType, so a duplicate handed over with Currency=0 read as "0 Tokens" -
// the "0, 0, 0 tokens" gift box players who already owned an activity's rewards kept seeing.
public static long DuplicateTokenValue(int giftRarity) => giftRarity switch
{
<= 10 => 25,
<= 20 => 50,
<= 30 => 100,
<= 40 => 200,
_ => 300
};
/// A token drop standing in for an item the player already owns. Keeps the row's id
/// so the selection can still be redeemed by it.
public static Reward AsDuplicateTokens(Reward r) => new Reward
{
Id = r.Id,
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = DuplicateTokenValue(r.GiftRarity),
Xp = r.Xp,
Level = r.Level,
GiftRarity = TokenRarity(DuplicateTokenValue(r.GiftRarity)),
Message = "Tokens",
Context = GiftContextGameRewardTokens,
Mode = r.Mode
};
/// Does the reward table know this activity (by gift context or by room/mode name)?
public static bool HasRewardsFor(int giftContext, string? modeName)
{
lock (_lock)
{
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var c) && c.Count > 0) return true;
if (!string.IsNullOrWhiteSpace(modeName))
{
string n = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(n, out var m) && m.Count > 0) return true;
}
return false;
}
}
///
/// Three token choices, for an activity the reward table doesn't know (GiftContext 0 /
/// unknown room). Drawing random items from EVERY activity's table here produced cards
/// the client couldn't render (blank image, "Reward") - a Game Complete box with nothing
/// readable in it. Tokens always render and always pay. Ids sit far above the table's.
///
// The token ladder a card can land on, 10..1000. Weighted so the big payouts are rare:
// the draw is skewed toward the low end (cube of a uniform roll), then snapped to a step
// so cards read cleanly (10, 25, 50 ... 750, 1000). Three DISTINCT amounts per offer.
private static readonly long[] TokenLadder = { 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 750, 1000 };
public static long RollTokenAmount()
{
double u;
lock (_lock) u = _rng.NextDouble();
// u^3 keeps ~87% of rolls in the bottom half of the ladder; 1000 lands ~1 in 60.
int idx = (int)Math.Floor(Math.Pow(u, 3) * TokenLadder.Length);
return TokenLadder[Math.Clamp(idx, 0, TokenLadder.Length - 1)];
}
/// The GiftContext the box is filed under: an activity's own id
/// (4206 Crescendo, 4003 Golden, ...) so the client draws THAT box, else the tokens box.
public static List TokenDrops(int boxContext = GiftContextGameRewardTokens)
{
var picked = new HashSet();
int guard = 0;
while (picked.Count < 3 && guard++ < 100) picked.Add(RollTokenAmount());
while (picked.Count < 3) foreach (var l in TokenLadder) { if (picked.Count >= 3) break; picked.Add(l); }
var amounts = picked.OrderBy(v => v).ToArray();
var list = new List();
for (int i = 0; i < amounts.Length; i++)
list.Add(new Reward
{
// Ids encode the amount so a selection can be redeemed by id after a restart.
Id = 1_000_000 + (int)amounts[i],
CurrencyType = (int)Campus_live_rewrite.api.server.Classes.CurrencyType.RecCenterTokens,
Currency = amounts[i],
Xp = Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
GiftRarity = TokenRarity(amounts[i]),
Message = "Tokens",
Context = boxContext
});
return list;
}
public static bool IsItem(Reward r) =>
!string.IsNullOrWhiteSpace(r.AvatarItemDesc) || !string.IsNullOrWhiteSpace(r.ConsumableItemDesc)
|| !string.IsNullOrWhiteSpace(r.EquipmentPrefabName) || !string.IsNullOrWhiteSpace(r.EquipmentModificationGuid);
///
/// Pick up to distinct rewards for a context, preferring items the player
/// does NOT own (reference: pickQuestReward draws "among the rows the player DOESN'T ALREADY
/// OWN"). Only when the unowned pool runs dry are owned items offered - as token drops.
///
public static List PickDistinct(int giftContext, string? modeName, int n, HashSet? ownedKeys)
{
var picked = PickDistinct(giftContext, modeName, n * 4);
if (ownedKeys == null) return picked.Take(n).ToList();
var fresh = new List();
var dupes = new List();
foreach (var r in picked)
{
bool owned = !string.IsNullOrWhiteSpace(r.AvatarItemDesc)
&& ownedKeys.Contains(PlayerDB.NormalizeAvatarItemDesc(r.AvatarItemDesc));
if (owned) dupes.Add(AsDuplicateTokens(r)); else fresh.Add(r);
}
var result = fresh.Take(n).ToList();
foreach (var d in dupes) { if (result.Count >= n) break; result.Add(d); }
return result;
}
public static List PickDistinct(int giftContext, string? modeName, int n)
{
lock (_lock)
{
var pool = new List();
bool hintGiven = giftContext != 0 || !string.IsNullOrWhiteSpace(modeName);
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var byCtx)) pool.AddRange(byCtx);
if (!string.IsNullOrWhiteSpace(modeName))
{
string nm = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(nm, out var byMode)) pool.AddRange(byMode);
}
// Prefer the RIGHT mode's/context's pool, but if the hint didn't resolve to anything
// (the client often sends giftContext=0 and a room name we don't have a pool for, e.g.
// Rec Royale post-game), fall back to the whole catalog rather than offering NOTHING -
// no reward at all is what made "the game-complete reward does nothing". A generic gift
// beats an empty hand.
if (pool.Count == 0) pool.AddRange(_all);
_ = hintGiven;
var result = new List();
if (pool.Count == 0) return result;
var seen = new HashSet();
var shuffled = pool.OrderBy(_ => _rng.Next()).ToList();
foreach (var r in shuffled)
{
if (result.Count >= n) break;
if (seen.Add(r.Id)) result.Add(r);
}
// Pad with repeats if the pool had fewer than n distinct entries.
for (int i = 0; result.Count < n && pool.Count > 0; i++)
result.Add(pool[i % pool.Count]);
return result;
}
}
/// An item drop's picture: the store row's own thumbnail when that file is hosted here, else ""
/// (an avatar item with no name makes the client draw the item itself; a name that 404s draws nothing).
public static string ItemThumbnail(Reward r) =>
!string.IsNullOrWhiteSpace(r.Thumbnail) && ItemImages.Exists(r.Thumbnail) ? r.Thumbnail! : "";
/// The client's GiftDrop wire shape for one offered reward choice (GameRewardDrop).
// The drop as the client's reward picker / gift box reads it. Currency, CurrencyType and Xp
// come off the row (reference: toQuestRewardDrop) - they used to be hardcoded 0 / absent,
// which is what made every non-item drop show as "0 Tokens" and no box show its XP.
public static object ToDrop(Reward r) => new
{
GiftDropId = r.Id,
FriendlyName = IsTokens(r) ? $"{r.Currency} Tokens"
: string.IsNullOrWhiteSpace(r.Message) ? "Reward" : r.Message,
Tooltip = IsTokens(r) ? $"{r.Currency} Rec Center Tokens" : "",
// A token drop names NO picture (null, as the real store's "60 Tokens" drop does): the
// card then renders the game's own 3D token pile. Our PNG only while
// UseClientCurrencyImages is off. An item drop draws its own thumbnail.
ThumbnailImageName = IsTokens(r) ? (string.IsNullOrEmpty(TokenThumbnail) ? null : TokenThumbnail)
: ItemThumbnail(r),
ConsumableItemDesc = r.ConsumableItemDesc ?? "",
AvatarItemDesc = r.AvatarItemDesc ?? "",
// NULL unless the drop IS an avatar item: the client routes a box on this field first,
// and a 0 on a token / consumable / skin drop sends it looking for "avatar item type 0"
// ("can't find avatar item"). Same rule as GiftBoxes / StoreCatalog.AvatarTypeFor.
AvatarItemType = string.IsNullOrWhiteSpace(r.AvatarItemDesc) ? (int?)null : 0,
EquipmentPrefabName = r.EquipmentPrefabName ?? "",
EquipmentModificationGuid = r.EquipmentModificationGuid ?? "",
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = r.GiftRarity,
// Items + XP only: tokens/currency are server/admin (see GameCurrency.IsPlayerGrantable).
CurrencyType = r.CurrencyType,
Currency = r.Currency,
Xp = r.Xp > 0 ? r.Xp : Campus_live_rewrite.api.server.Classes.Progress.GAME_REWARD_XP,
Context = r.Context,
// The rest of the real GiftDrop shape (store dump, GiftDropId 4014 "60 Tokens").
// ItemSetId was 1 here - a set id sends the card's imposter renderer after "item set 1",
// which doesn't exist, so a token card with no named picture drew nothing (blank tiles).
ItemSetId = (int?)null,
ItemSetFriendlyName = "",
ItemCount = 1,
AvatarItemId = 0,
EquipmentItemId = 0,
CustomAvatarItemId = (string?)null,
TagList = ""
};
/// Roll a random reward for a GiftContext, else for a mode name, else null.
public static Reward? Pick(int giftContext, string? modeName = null)
{
lock (_lock)
{
if (giftContext != 0 && _byContext.TryGetValue(giftContext, out var byCtx) && byCtx.Count > 0)
return byCtx[_rng.Next(byCtx.Count)];
if (!string.IsNullOrWhiteSpace(modeName))
{
string n = modeName.TrimStart('^').Trim();
if (_byMode.TryGetValue(n, out var byMode) && byMode.Count > 0)
return byMode[_rng.Next(byMode.Count)];
}
return null;
}
}
}
}