441 lines
21 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|