Files
Campus-25/static/Servers/Campus/api/server/RewardSelections.cs
T
2026-10-14 06:59:51 +02:00

197 lines
9.8 KiB
C#

using System.Collections.Concurrent;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// 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).
/// </summary>
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<QuestRewards.Reward> Drops = new(); // the three offered, in order
public bool Consumed;
/// <summary>The GiftDropId the player picked (0 for selections consumed before this existed).</summary>
public int ChosenDropId;
public DateTime CreatedAt = DateTime.UtcNow;
}
private static int _next = 0;
private static readonly ConcurrentDictionary<int, Selection> _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<LiteDB.LiteDatabase> _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<Row>("selections");
int max = 0, n = 0;
foreach (var r in col.FindAll())
{
var s = Newtonsoft.Json.JsonConvert.DeserializeObject<Selection>(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<Row>("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<QuestRewards.Reward> 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; }
/// <summary>
/// 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.
/// </summary>
public static Selection? Offer(ulong accountId, int giftContext, string? modeName, string? message)
{
var owned = PlayerDB.GetOwnedAvatarItemKeys(accountId);
bool known = QuestRewards.HasRewardsFor(giftContext, modeName);
List<QuestRewards.Reward> 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;
}
/// <summary>The caller's still-unclaimed selections, oldest first (GET /gamerewards/v1/pending).</summary>
public static List<Selection> PendingFor(ulong accountId)
{
EnsureLoaded();
Prune();
return _store.Values.Where(s => s.AccountId == accountId && !s.Consumed)
.OrderBy(s => s.RewardSelectionId).ToList();
}
/// <summary>The selection as the client reads it - the RewardSelectionReceived frame's Msg
/// and one entry of GET /gamerewards/v1/pending are this same shape.</summary>
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")
};
/// <summary>Mark consumed. Returns false if it was already consumed (single-use, race-safe).</summary>
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<Row>("selections").Delete(kv.Key); } catch { }
}
}
}
}