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

441 lines
21 KiB
C#

using LiteDB;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// Limited-time progression events - the watch's "Limited Time Challenge" (Summer Party and
/// friends): a reward track with a free row and an RR+ row, filled by XP earned from time spent
/// in public rooms, capped per day. The client has the whole feature (RRUI.Data.
/// ProgressionEventsModel); Rec Room's servers supplied the events, so Campus defines them in
/// the admin panel (Challenges > Limited Time Events), stored in Data/APIS/progression-events.json.
///
/// The wire shapes were read out of the running 2025 client (the DTO formatters' name tables):
/// RecNet.ProgressionEventDTO / ProgressionEventRewardDTO / ProgressionEventRecordDTO. Which DTO
/// owns which name is only partly resolved, so each object is sent as a SUPERSET of the
/// candidate names - the client's decoder drops unknown keys silently. Every other call the
/// client makes under /api/progressionEvents is logged (ProgressionEventsController) so the
/// exact routes name themselves on the first test. An event can be set Developers-only so a
/// wrong guess only ever reaches staff.
///
/// XP rule (the client's own gameconfig: ProgressionEvents.* keys in Data/APIS/Gameconfigs.json):
/// every XpMinutes minutes in a public room pays XpPerConversion XP, at most MaxDailyXp per UTC day.
/// </summary>
public static class ProgressionEvents
{
// ---------------- definitions ----------------
public sealed class Reward
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>"tokens", "item" or "none".</summary>
public string Kind { get; set; } = "tokens";
public long Tokens { get; set; } = 50;
public int GiftDropId { get; set; } // store gift drop, for "item"
public string? AvatarItemDesc { get; set; }
public int AvatarItemType { get; set; }
public string? FriendlyName { get; set; }
public int Rarity { get; set; }
public string? ImageName { get; set; }
}
/// <summary>One column of the track: the XP to reach it, the free reward and the RR+ reward.</summary>
public sealed class Step
{
public int Xp { get; set; } = 100;
public Reward? Free { get; set; }
public Reward? Premium { get; set; }
}
public sealed class EventDef
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The id the GAME uses. The client reads GET /api/progressionEvents/active as a NUMBER
/// (Player.log: "expected:'Number Token', actual:'['" for both our [] and our event
/// list), and its reward model is Set(long, int) - so events (and rewards) are
/// addressed by small integers, starting at 1. Assigned on save, never reused.
/// </summary>
public long NumericId { get; set; }
public string Name { get; set; } = "Summer Party!";
public DateTime StartTime { get; set; } = DateTime.UtcNow.Date;
public DateTime EndTime { get; set; } = DateTime.UtcNow.Date.AddDays(14);
/// <summary>Rewards can still be collected (no more XP) until this; null = EndTime.</summary>
public DateTime? CollectionEndTime { get; set; }
public bool Enabled { get; set; }
/// <summary>Only players with the developer flag see it - for testing a new event.</summary>
public bool DevelopersOnly { get; set; } = true;
/// <summary>RR+ row collectable by everyone (Campus has no RR+ purchase).</summary>
public bool PremiumForEveryone { get; set; } = true;
public string? ChallengesIconImageName { get; set; }
public string? EventInfoImageName { get; set; }
public List<Step> Steps { get; set; } = new();
}
private static readonly object _lock = new();
private static readonly string File1 = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "progression-events.json");
private static List<EventDef>? _defs;
public static List<EventDef> All()
{
lock (_lock)
{
if (_defs != null) return _defs;
try
{
_defs = File.Exists(File1)
? Newtonsoft.Json.JsonConvert.DeserializeObject<List<EventDef>>(File.ReadAllText(File1)) ?? new()
: new();
}
catch (Exception ex) { Console.WriteLine($"[progression-events] could not read progression-events.json: {ex.Message}"); _defs = new(); }
AssignNumericIds(_defs);
return _defs;
}
}
/// <summary>Give every event without one a numeric id (max + 1, starting at 1).</summary>
private static void AssignNumericIds(List<EventDef> defs)
{
long next = Math.Max(defs.Count == 0 ? 0 : defs.Max(d => d.NumericId), _highestNumericId) + 1;
foreach (var d in defs.Where(d => d.NumericId <= 0)) d.NumericId = next++;
_highestNumericId = Math.Max(_highestNumericId, next - 1);
}
private static long _highestNumericId;
public static EventDef? FindNumeric(long id) => All().FirstOrDefault(e => e.NumericId == id);
/// <summary>A reward's game-facing id: event id * 1000 + its position in the track (free, RR+ per step).</summary>
public static long RewardNumericId(EventDef e, int stepIndex, bool premium) => e.NumericId * 1000 + stepIndex * 2 + (premium ? 1 : 0);
public static void SaveAll(List<EventDef> defs)
{
lock (_lock)
{
AssignNumericIds(defs);
Directory.CreateDirectory(Path.GetDirectoryName(File1)!);
File.WriteAllText(File1, Newtonsoft.Json.JsonConvert.SerializeObject(defs, Newtonsoft.Json.Formatting.Indented));
_defs = defs;
}
}
public static EventDef? Find(string? id) =>
id == null ? null : All().FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase));
private static DateTime CollectEnd(EventDef e) => e.CollectionEndTime ?? e.EndTime;
/// <summary>Events this player is shown right now (running, or in their collection window).</summary>
public static List<EventDef> VisibleTo(ulong accountId)
{
var now = DateTime.UtcNow;
bool dev = false; bool devChecked = false;
var list = new List<EventDef>();
foreach (var e in All())
{
if (!e.Enabled || now < e.StartTime || now > CollectEnd(e)) continue;
if (e.DevelopersOnly)
{
if (!devChecked) { dev = accountId != 0 && PlayerDB.CheckDevFlag(accountId); devChecked = true; }
if (!dev) continue;
}
list.Add(e);
}
return list;
}
// ---------------- game config (the client's own numbers) ----------------
private static (int minutes, int xp, int cap)? _rule;
public static (int minutes, int xp, int cap) Rule()
{
if (_rule is { } r) return r;
int minutes = 5, xp = 10, cap = 120;
try
{
var arr = JArray.Parse(File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "Gameconfigs.json")));
string? V(string key) => arr.OfType<JObject>().FirstOrDefault(o => (string?)o["Key"] == key)?["Value"]?.ToString();
if (int.TryParse(V("ProgressionEvents.Game minutes for xp conversion"), out var m) && m > 0) minutes = m;
if (int.TryParse(V("ProgressionEvents.Xp amount per conversion"), out var x) && x > 0) xp = x;
if (int.TryParse(V("ProgressionEvents.Max daily Xp"), out var c) && c > 0) cap = c;
}
catch { }
_rule = (minutes, xp, cap);
return _rule.Value;
}
// ---------------- per-player records ----------------
public sealed class Record
{
[BsonId] public string Id { get; set; } = ""; // "{eventId}:{accountId}"
public string EventId { get; set; } = "";
public long AccountId { get; set; }
public int Xp { get; set; }
public DateTime Day { get; set; } // UTC date XpToday / GameMinutesToday belong to
public int XpToday { get; set; }
public double SecondsToday { get; set; }
public double SecondsUnconverted { get; set; }
public List<string> Collected { get; set; } = new();
public DateTime UpdatedAt { get; set; }
}
private static readonly Lazy<LiteDatabase> _db = new(() =>
{
Directory.CreateDirectory(Path.Combine("Data", "DBs"));
return new LiteDatabase(Path.Combine("Data", "DBs", "ProgressionEvents.db"));
});
private static ILiteCollection<Record> Records => _db.Value.GetCollection<Record>("records");
public static Record GetRecord(string eventId, ulong accountId)
{
string id = $"{eventId}:{accountId}";
var r = Records.FindById(id) ?? new Record { Id = id, EventId = eventId, AccountId = (long)accountId, Day = DateTime.UtcNow.Date };
if (r.Day != DateTime.UtcNow.Date) { r.Day = DateTime.UtcNow.Date; r.XpToday = 0; r.SecondsToday = 0; }
return r;
}
private static void Put(Record r) { r.UpdatedAt = DateTime.UtcNow; Records.Upsert(r); }
public static void SaveRecord(Record r) => Put(r);
public static int Participants(string eventId) => Records.Count(x => x.EventId == eventId);
public static void ResetPlayer(string eventId, ulong accountId) => Records.Delete($"{eventId}:{accountId}");
// ---------------- XP from time in public rooms ----------------
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _lastBeat = new();
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, DateTime> _lastSave = new();
/// <summary>
/// Called from every presence heartbeat. Time between two heartbeats (capped, so a gap or a
/// sleeping client never pays) spent in a PUBLIC room counts toward every running event.
/// </summary>
public static void OnHeartbeat(ulong accountId)
{
try
{
var now = DateTime.UtcNow;
var prev = _lastBeat.TryGetValue(accountId, out var p) ? p : (DateTime?)null;
_lastBeat[accountId] = now;
if (prev == null) return;
double secs = (now - prev.Value).TotalSeconds;
if (secs <= 0 || secs > 90) return;
var events = VisibleTo(accountId).Where(e => now <= e.EndTime).ToList();
if (events.Count == 0) return;
var inst = HeartbeatDB.GetPlayerHeartbeat(accountId)?.roomInstance;
if (inst == null || inst.isPrivate || inst.roomId <= 0) return;
var room = RoomDB.GetRoom((ulong)inst.roomId);
if (room == null || room.IsDorm) return;
var (minutes, perConv, cap) = Rule();
foreach (var e in events)
{
var r = GetRecord(e.Id, accountId);
r.SecondsToday += secs;
bool gained = false;
if (r.XpToday < cap)
{
r.SecondsUnconverted += secs;
while (r.SecondsUnconverted >= minutes * 60 && r.XpToday < cap)
{
r.SecondsUnconverted -= minutes * 60;
int add = Math.Min(perConv, cap - r.XpToday);
r.Xp += add; r.XpToday += add; gained = true;
}
}
// Persist on every XP gain, otherwise at most once a minute per player.
bool due = !_lastSave.TryGetValue(accountId, out var ls) || (now - ls).TotalSeconds >= 60;
if (gained || due) { Put(r); _lastSave[accountId] = now; }
if (gained) PushRecord(e, r);
}
}
catch (Exception ex) { Console.WriteLine($"[progression-events] heartbeat {accountId}: {ex.Message}"); }
}
// ---------------- collecting ----------------
private static IEnumerable<(int index, int cumXp, Reward reward, bool premium)> Track(EventDef e)
{
int cum = 0;
for (int i = 0; i < e.Steps.Count; i++)
{
cum += Math.Max(0, e.Steps[i].Xp);
if (e.Steps[i].Free is { } f && f.Kind != "none") yield return (i, cum, f, false);
if (e.Steps[i].Premium is { } pr && pr.Kind != "none") yield return (i, cum, pr, true);
}
}
/// <summary>
/// Collect rewards the player has reached. <paramref name="rewardId"/> = one reward (when
/// the client names it), else every reached, uncollected reward. Returns how many were paid.
/// </summary>
public static int Collect(EventDef e, ulong accountId, string? rewardId)
{
if (DateTime.UtcNow > CollectEnd(e)) return 0;
var r = GetRecord(e.Id, accountId);
int paid = 0;
foreach (var (index, cum, reward, premium) in Track(e))
{
if (rewardId != null && !string.Equals(reward.Id, rewardId, StringComparison.OrdinalIgnoreCase)) continue;
if (r.Collected.Contains(reward.Id) || r.Xp < cum) continue;
if (premium && !e.PremiumForEveryone) continue;
if (!Grant(accountId, e, reward, index)) continue;
r.Collected.Add(reward.Id); paid++;
}
if (paid > 0) { Put(r); PushRecord(e, r); }
Console.WriteLine($"[progression-events] {accountId} collect on '{e.Name}'{(rewardId != null ? $" reward {rewardId}" : "")}: {paid} reward(s) paid (xp {r.Xp})");
return paid;
}
private static bool Grant(ulong accountId, EventDef e, Reward reward, int index)
{
string msg = $"{e.Name} - reward {index + 1}";
PendingGiftBox box = reward.Kind == "item" && !string.IsNullOrWhiteSpace(reward.AvatarItemDesc)
? new PendingGiftBox
{
GiftContext = GiftContextLimitedTimeEvent, Message = msg, FromAccountId = 0,
AvatarItemDesc = reward.AvatarItemDesc, AvatarItemType = reward.AvatarItemType,
FriendlyName = reward.FriendlyName, GiftDropId = reward.GiftDropId, Rarity = reward.Rarity
}
: new PendingGiftBox
{
GiftContext = GiftContextLimitedTimeEvent, Message = msg, FromAccountId = 0,
FriendlyName = $"{reward.Tokens} Tokens",
ThumbnailImageName = QuestRewards.TokenThumbnail,
CurrencyType = (int)CurrencyType.RecCenterTokens, Currency = reward.Tokens,
Rarity = QuestRewards.TokenRarity(reward.Tokens), PayOnOpen = true
};
if (!PlayerDB.AddPendingGiftBoxFull(accountId, box)) return false;
GiftBoxes.Push(accountId, box, GiftBoxes.FrameReceivedImmediate, $"event '{e.Name}' reward");
return true;
}
// GiftContext "LimitedTimeEvent": the client's enum lists it (read from the live metadata)
// but its number is unverified, so boxes go out under the plain token-rewards context,
// which is known to draw.
public const int GiftContextLimitedTimeEvent = QuestRewards.GiftContextGameRewardTokens;
// ---------------- wire shapes (superset of the candidate names) ----------------
private static string Iso(DateTime t) => DateTime.SpecifyKind(t, DateTimeKind.Utc).ToString("o");
public static JObject RewardDto(EventDef e, Step step, Reward reward, int index, bool premium) => new()
{
["ProgressionEventRewardId"] = RewardNumericId(e, index, premium),
["ProgressionEventId"] = e.NumericId,
["RewardIndex"] = index,
["Index"] = index,
["Xp"] = step.Xp,
["RequiredXp"] = step.Xp,
["GiftDropId"] = reward.Kind == "item" ? reward.GiftDropId : TokenGiftDropBase + index * 2 + (premium ? 1 : 0),
["ImageName"] = string.IsNullOrWhiteSpace(reward.ImageName)
? (reward.Kind == "item" ? "" : ConfigCampus.LevelRewardImage) : reward.ImageName,
["IsRRPlusExclusive"] = premium,
["IsBonus"] = false
};
/// <summary>Synthetic gift drop ids for token rewards (items use their real store id).</summary>
public const int TokenGiftDropBase = 8_800_000;
public static JObject EventDto(EventDef e)
{
var rewards = new JArray();
for (int i = 0; i < e.Steps.Count; i++)
{
var s = e.Steps[i];
if (s.Free is { } f && f.Kind != "none") rewards.Add(RewardDto(e, s, f, i, false));
if (s.Premium is { } p && p.Kind != "none") rewards.Add(RewardDto(e, s, p, i, true));
}
var (minutes, perConv, cap) = Rule();
return new JObject
{
["ProgressionEventId"] = e.NumericId,
["Id"] = e.NumericId,
["Name"] = e.Name,
["StartTime"] = Iso(e.StartTime),
["EndTime"] = Iso(e.EndTime),
["CollectionEndTime"] = Iso(CollectEnd(e)),
["Rewards"] = rewards,
["ActiveExperiments"] = new JArray(),
["KeepsakeRooms"] = new JArray(),
["KeepsakeRoomLists"] = new JArray(),
["CollectionRecords"] = new JArray(),
["UsesBoost"] = false,
["BoostXpMultiplier"] = 1,
["BoostDailyGameplayMinutesLimit"] = 0,
["ProgressionEventPurchasableXpBoostId"] = null,
["ChallengesIconImageName"] = e.ChallengesIconImageName ?? "",
["EventInfoImageName"] = e.EventInfoImageName ?? "",
["XpCap"] = cap,
["DailyLimit"] = cap
};
}
public static JObject RecordDto(EventDef e, Record r)
{
int free = 0, premium = 0;
foreach (var (_, _, reward, prem) in Track(e))
if (r.Collected.Contains(reward.Id)) { if (prem) premium++; else free++; }
return new JObject
{
["ProgressionEventId"] = e.NumericId,
["AccountId"] = r.AccountId,
["Xp"] = r.Xp,
["TotalXp"] = r.Xp,
["GameMinutesToday"] = (int)(r.SecondsToday / 60),
["RewardsCollected"] = free,
["PremiumRewardsCollected"] = premium,
["BonusRewardsCollected"] = 0,
["XpBoostLastPurchasedAt"] = null
};
}
public static void PushRecord(EventDef e, Record r)
{
try
{
var frame = Newtonsoft.Json.JsonConvert.SerializeObject(new WebsocketEvents.Reponse { Id = "ProgressionEventsRecordUpdate", Msg = RecordDto(e, r) });
Notifications.FireAndForget(Notifications.SendToPlayer((ulong)r.AccountId, frame), "progression record");
}
catch { }
}
// ---------------- store items for the admin picker ----------------
public sealed class StoreItem
{
public int GiftDropId; public string FriendlyName = ""; public string AvatarItemDesc = "";
public int AvatarItemType; public int Rarity; public string ThumbnailImageName = "";
}
private static List<StoreItem>? _store;
public static List<StoreItem> StoreItems()
{
if (_store != null) return _store;
var list = new List<StoreItem>();
try
{
string file = Path.Combine(StoreCatalog.StoreDirectory, "3.json");
foreach (var it in JObject.Parse(File.ReadAllText(file))["StoreItems"] as JArray ?? new JArray())
{
if (it["GiftDrop"] is not JObject gd || (bool?)gd["IsQuery"] == true) continue;
string desc = (string?)gd["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
list.Add(new StoreItem
{
GiftDropId = (int?)gd["GiftDropId"] ?? 0, FriendlyName = (string?)gd["FriendlyName"] ?? "",
AvatarItemDesc = desc, AvatarItemType = (int?)gd["AvatarItemType"] ?? 0,
Rarity = (int?)gd["Rarity"] ?? 0, ThumbnailImageName = (string?)gd["ThumbnailImageName"] ?? ""
});
}
}
catch (Exception ex) { Console.WriteLine($"[progression-events] store read failed: {ex.Message}"); }
_store = list;
return list;
}
}
}