using System.Collections.Concurrent; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api; namespace Campus_live_rewrite.api.server { /// /// The three-choice reward the client shows on the watch after a game/quest. POST /// /api/gamerewards/v1/request mints one (three offered drops) and pushes it over the /// notifications hub as "RewardSelectionReceived"; POST /api/gamerewards/v1/select consumes it /// and grants the chosen drop. The offered drops are recorded so select can verify the player /// is claiming one they were actually offered, and Consumed makes it single-use. /// /// In-memory + auto-increment id + a TTL, mirroring RoomInvites - a selection is picked within /// seconds of being offered, so it needn't survive a restart (the player just re-triggers it). /// public static class RewardSelections { public sealed class Selection { public int RewardSelectionId; public ulong AccountId; public string Message = ""; public int GiftContext; public int RewardType; public List Drops = new(); // the three offered, in order public bool Consumed; /// The GiftDropId the player picked (0 for selections consumed before this existed). public int ChosenDropId; public DateTime CreatedAt = DateTime.UtcNow; } private static int _next = 0; private static readonly ConcurrentDictionary _store = new(); // Persisted: a selection offered right before a redeploy/restart used to vanish, so the // pick the player made seconds later was a 403 ("Failed to consume reward N"). Rows are // the Selection as JSON keyed by id; loaded once, written on create and on consume. public sealed class Row { [LiteDB.BsonId] public int Id { get; set; } public string Json { get; set; } = ""; public DateTime CreatedAt { get; set; } } private static readonly Lazy _db = new(() => new LiteDB.LiteDatabase(Path.Combine("Data", "DBs", "RewardSelections.db"))); private static bool _loaded; private static readonly object _loadLock = new(); private static void EnsureLoaded() { if (_loaded) return; lock (_loadLock) { if (_loaded) return; try { var col = _db.Value.GetCollection("selections"); int max = 0, n = 0; foreach (var r in col.FindAll()) { var s = Newtonsoft.Json.JsonConvert.DeserializeObject(r.Json); if (s == null) continue; _store[s.RewardSelectionId] = s; if (s.RewardSelectionId > max) max = s.RewardSelectionId; n++; } if (max > _next) _next = max; if (n > 0) Console.WriteLine($"[gamereward] restored {n} reward selection(s), next id {_next + 1}"); } catch (Exception ex) { Console.WriteLine($"[gamereward] could not restore selections: {ex.Message}"); } _loaded = true; } } private static void Persist(Selection s) { try { _db.Value.GetCollection("selections").Upsert(new Row { Id = s.RewardSelectionId, Json = Newtonsoft.Json.JsonConvert.SerializeObject(s), CreatedAt = s.CreatedAt }); } catch (Exception ex) { Console.WriteLine($"[gamereward] could not persist selection {s.RewardSelectionId}: {ex.Message}"); } } // Matches the served Rewards.SelectionTimeout (48 h). The client runs an expiry timer of // (CreatedAt + timeout - its own server-time estimate) and AUTO-PICKS card 1 + closes the // watch when it fires; a PC whose clock/zone put "server time" 30+ min ahead of CreatedAt // saw the old 30-min window as already expired and had the reward chosen for it within // seconds of the offer. The window now outlives any timezone skew, so a selection must stay // redeemable here for as long as the client thinks it is open. private static readonly TimeSpan TTL = TimeSpan.FromHours(48); public static Selection Create(ulong accountId, string message, int giftContext, int rewardType, List drops) { EnsureLoaded(); Prune(); int id = System.Threading.Interlocked.Increment(ref _next); var s = new Selection { RewardSelectionId = id, AccountId = accountId, Message = message ?? "", GiftContext = giftContext, RewardType = rewardType, Drops = drops }; _store[id] = s; Persist(s); return s; } public static Selection? Get(int id) { EnsureLoaded(); return _store.TryGetValue(id, out var s) ? s : null; } /// /// Mint a three-card reward selection for a player and push it to their watch - the /// same thing POST /api/gamerewards/v1/request does after a game. Tokens (10..1000) in the /// activity's box unless item rewards are enabled and the table knows the context. /// Returns the selection, or null when nothing could be offered. /// public static Selection? Offer(ulong accountId, int giftContext, string? modeName, string? message) { var owned = PlayerDB.GetOwnedAvatarItemKeys(accountId); bool known = QuestRewards.HasRewardsFor(giftContext, modeName); List drops; if (ConfigCampus.RewardItemsEnabled && known) drops = QuestRewards.PickDistinct(giftContext, modeName, 3, owned); else { // Watch-store clothing (2-4 stars) the player doesn't own, then tokens for the rest. int want = Math.Clamp(ConfigCampus.RewardWatchClothingCards, 0, 3); drops = QuestRewards.WatchClothingDrops(giftContext, want, owned); if (drops.Count < 3) drops.AddRange(QuestRewards.TokenDrops(giftContext != 0 ? giftContext : QuestRewards.GiftContextGameRewardTokens) .OrderBy(_ => Random.Shared.Next()).Take(3 - drops.Count)); } if (drops.Count != 3) return null; string selMsg = string.IsNullOrWhiteSpace(message) ? "Activity completed!" : message!; var sel = Create(accountId, selMsg, giftContext, 0, drops); var frame = Newtonsoft.Json.JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "RewardSelectionReceived", Msg = ToPayload(sel) }); double delay = ConfigCampus.RewardSelectionPushDelaySeconds; Notifications.FireAndForget(System.Threading.Tasks.Task.Run(async () => { if (delay > 0) await System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(delay)); await Notifications.SendToPlayer(accountId, frame); }), "RewardSelectionReceived"); Console.WriteLine($"[gamereward] {accountId} offered reward selection {sel.RewardSelectionId} " + $"ctx={giftContext} room='{modeName}' drops=[{string.Join(",", drops.Select(d => d.Id))}] in box {drops[0].Context}"); return sel; } /// The caller's still-unclaimed selections, oldest first (GET /gamerewards/v1/pending). public static List PendingFor(ulong accountId) { EnsureLoaded(); Prune(); return _store.Values.Where(s => s.AccountId == accountId && !s.Consumed) .OrderBy(s => s.RewardSelectionId).ToList(); } /// The selection as the client reads it - the RewardSelectionReceived frame's Msg /// and one entry of GET /gamerewards/v1/pending are this same shape. public static object ToPayload(Selection sel) => new { RewardSelectionId = sel.RewardSelectionId, RewardType = sel.RewardType, Message = sel.Message, GiftContext = sel.GiftContext, GiftDrop1 = QuestRewards.ToDrop(sel.Drops[0]), GiftDrop2 = QuestRewards.ToDrop(sel.Drops[1]), GiftDrop3 = QuestRewards.ToDrop(sel.Drops[2]), // The reference sends the third drop again under the subscriber key; with no // subscriber-only pool the two are the same drop. Subscriber_GiftDrop3 = QuestRewards.ToDrop(sel.Drops[2]), CreatedAt = sel.CreatedAt.AddSeconds(ConfigCampus.RewardSelectionCreatedAtOffsetSeconds).ToString("o") }; /// Mark consumed. Returns false if it was already consumed (single-use, race-safe). public static bool Consume(int id, int chosenDropId = 0) { if (!_store.TryGetValue(id, out var s)) return false; lock (s) { if (s.Consumed) return false; s.Consumed = true; s.ChosenDropId = chosenDropId; } Persist(s); return true; } private static void Prune() { var cutoff = DateTime.UtcNow - TTL; foreach (var kv in _store) if (kv.Value.CreatedAt < cutoff) { _store.TryRemove(kv.Key, out _); try { _db.Value.GetCollection("selections").Delete(kv.Key); } catch { } } } } }