From f6a4fdc5fbd681291f90e48d4cf6729b1ccfe5e6 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 31 May 2026 14:47:12 +0300 Subject: [PATCH] weekly challenges: admin-configurable slate + working skin rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the admin-editable weekly-challenge feature to the 2020.03 branch and fix the skin-reward bug it had on December: rewards now actually land in the player's inventory instead of only granting XP + tokens. Server config: add WeeklyChallenges* columns to ServerSettings (Postgres post-EnsureCreated patches + SQLite column patches), and port the ServerSettingsService weekly methods/records. The reward record carries the store item Slug — the reliable handle December lacked — so the grant can resolve the real item. ProgressionController: CurrentChallenge + updateProgress now read the configured slate and reward instead of a hardcoded one, keeping the 2020.03 ChallengeMap/ChallengeGift wire shapes (verified against the march dump: ChallengeGift TypeDefIndex 11455). The Gift now sends AvatarItemType (Outfit when an AvatarItemDesc is set) so the watch's gift-card render binds the avatar preview. On completing the week's challenges the configured skin/consumable is granted via the new StoreService.GrantItemFreeBySlugAsync (same inventory path as a purchase, no charge), gated behind the once-only reward sentinel. Admin: GET/POST settings/weekly-challenges + reward-options (avatar + consumable only — 2020.03 StoreService has no equipment payload helper), and the admin-ui Server settings page to edit the slate + reward. The reward picker keys on item slug rather than December's fragile descriptor-string match. Docs: README no longer lists weekly challenges as December-only; url-coverage notes the config-driven challenge endpoints + skin grant. --- .../API/Progression/ProgressionController.cs | 125 ++++-- .../Controllers/Admin/AdminController.cs | 87 ++++ .../Data/Entities/ServerSettingsEntity.cs | 19 + DorkNet.Server/Data/url-coverage.md | 4 +- DorkNet.Server/Program.cs | 21 + .../Services/ServerSettingsService.cs | 204 ++++++++- DorkNet.Server/Services/StoreService.cs | 56 +++ .../admin-ui/src/pages/Settings.tsx | 393 +++++++++++++++++- README.md | 7 +- 9 files changed, 858 insertions(+), 58 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs b/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs index 192f8a8..4872093 100644 --- a/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs +++ b/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs @@ -18,7 +18,11 @@ namespace DorkNet.Server.Controllers.API.ProgressionApi; /// obj:{group}:{index}. /// [ApiController] -public class ProgressionController(DorkNetDbContext db, LevelService level) : ControllerBase +public class ProgressionController( + DorkNetDbContext db, + LevelService level, + ServerSettingsService serverSettings, + StoreService store) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); private long? MeOrNull => this.CurrentPlayerId(); @@ -56,50 +60,87 @@ public class ProgressionController(DorkNetDbContext db, LevelService level) : Co var start = now.Date.AddDays(-(int)now.DayOfWeek); var mapId = CurrentChallengeMapId(); - var keys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" }; - var rows = pid is long me + var weekly = await serverSettings.GetWeeklyChallengesAsync(); + var defs = weekly.Challenges; + + var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray(); + var rows = pid is long me && keys.Length > 0 ? await db.ObjectiveProgress .Where(o => o.PlayerId == me && keys.Contains(o.Key)) .ToDictionaryAsync(o => o.Key, o => o.IsCompleted) : new Dictionary(); - bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v; + bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v; + var reward = weekly.Reward; return Ok(new { ChallengeMapId = mapId, - CompletedRequired = false, + CompletedRequired = weekly.CompletedRequired, StartAt = start, EndAt = start.AddDays(7), ServerTime = now, ChallengeThemeString = "Weekly", - Challenges = new[] + Challenges = defs.Select(c => new { - new { ChallengeId = mapId * 10 + 0, Name = "Visit any room", - Config = "{\"Goal\":1}", Description = "Step into any room this week.", - Tooltip = "", Complete = Done(0) }, - new { ChallengeId = mapId * 10 + 1, Name = "Cheer a player", - Config = "{\"Goal\":1}", Description = "Send a cheer to anyone you meet this week.", - Tooltip = "", Complete = Done(1) }, - new { ChallengeId = mapId * 10 + 2, Name = "Finish an activity", - Config = "{\"Goal\":1}", Description = "Complete any activity this week.", - Tooltip = "", Complete = Done(2) }, - }, - Gift = new - { - GiftDropId = mapId, - AvatarItemDesc = "", - ConsumableItemDesc = "", - EquipmentPrefabName = "", - EquipmentModificationGuid = "", - Xp = 100, - Level = 1, - GiftContext = 0, - GiftRarity = 0, - }, + ChallengeId = ChallengeId(mapId, c.Index), + c.Name, + c.Config, + c.Description, + c.Tooltip, + Complete = Done(c.Index), + }), + Gift = BuildGiftDto(mapId, reward), }); } + /// Project a onto the + /// 2020.03 ChallengeGift wire shape (dump.cs ChallengeGift, + /// TypeDefIndex 11455). AvatarItemType is sent as + /// Outfit (0) whenever an AvatarItemDesc is present and + /// null otherwise — the client's gift-card render binds the + /// avatar preview through it, and an AvatarItemDesc with no + /// AvatarItemType leaves that binding half-populated (same + /// failure mode StoreService documents for the shop grid). + private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new + { + GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId, + reward.AvatarItemDesc, + AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc) + ? (int?)null + : StoreAvatarItemTypeOutfit, + reward.ConsumableItemDesc, + reward.EquipmentPrefabName, + reward.EquipmentModificationGuid, + Xp = reward.Xp, + reward.Level, + GiftContext = reward.GiftContext, + GiftRarity = reward.GiftRarity, + }; + + /// AvatarItemType.Outfit — kept in sync with + /// StoreService's constant of the same value. + private const int StoreAvatarItemTypeOutfit = 0; + + // ObjectiveProgress row key for a weekly challenge slot. + private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}"; + + // Stable per-map ChallengeId the watch round-trips: mapId*10 + slot + // index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges + // enforces the same Take(10)). + private static int ChallengeId(int mapId, int index) => mapId * 10 + index; + + // Recover the slot index from whatever the watch echoes back. Prefer + // the mapId*10 + index encoding; fall back to the low digit for + // clients that send a bare id. Clamp into the configured range. + private static int ChallengeIndex(int mapId, int challengeId, int challengeCount) + { + if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10) + return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1); + + return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1); + } + public sealed class UpdateChallengeRequest { public int Id { get; set; } @@ -116,12 +157,12 @@ public class ProgressionController(DorkNetDbContext db, LevelService level) : Co var req = await ReadChallengeRequestAsync(formReq); var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId(); var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id; - var index = challengeId >= mapId * 10 && challengeId < mapId * 10 + 10 - ? challengeId - mapId * 10 - : Math.Abs(challengeId % 10); - index = Math.Clamp(index, 0, 2); - var key = $"challenge:{mapId}:{index}"; + var weekly = await serverSettings.GetWeeklyChallengesAsync(); + var challengeCount = Math.Max(1, weekly.Challenges.Count); + var index = ChallengeIndex(mapId, challengeId, challengeCount); + + var key = ChallengeProgressKey(mapId, index); var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); if (row is null) { @@ -136,17 +177,29 @@ public class ProgressionController(DorkNetDbContext db, LevelService level) : Co await db.SaveChangesAsync(); var weeklyRewarded = false; - var allKeys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" }; + var allKeys = weekly.Challenges + .Select(c => ChallengeProgressKey(mapId, c.Index)) + .ToArray(); var completeCount = await db.ObjectiveProgress .CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted); if (completeCount >= allKeys.Length) { + var reward = weekly.Reward; weeklyRewarded = await GrantRewardOnceAsync( pid, $"weekly-challenge:{mapId}", - xp: 250, - tokens: 250, + xp: reward.Xp, + tokens: reward.Tokens, reason: $"weekly_challenge:{mapId}"); + + // Land the actual skin/consumable, not just XP + tokens — the + // December build only granted currency, so configured item + // rewards showed on the watch but never reached the player's + // inventory. Gated behind the same once-only sentinel so a + // re-submit doesn't re-grant: only grant the item on the boot + // transition where currency was also granted. + if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug)) + await store.GrantItemFreeBySlugAsync(pid, reward.Slug); } return Ok(new diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index b7c5b45..98893b1 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1167,6 +1167,7 @@ public class AdminController( return Ok(new { row.SignupsDisabled, + row.WeeklyChallengesCompletedRequired, row.UpdatedAt, }); } @@ -1188,10 +1189,96 @@ public class AdminController( return Ok(new { row.SignupsDisabled, + row.WeeklyChallengesCompletedRequired, row.UpdatedAt, }); } + // ── Weekly challenges ──────────────────────────────────────────────── + + /// GET api/admin/v1/settings/weekly-challenges — the + /// current weekly slate + reward the watch's challenge map is built + /// from (). + [HttpGet("settings/weekly-challenges")] + public async Task GetWeeklyChallenges() + { + var weekly = await serverSettings.GetWeeklyChallengesAsync(); + return Ok(weekly); + } + + /// GET + /// api/admin/v1/settings/weekly-challenges/reward-options — + /// the store items that can be assigned as the weekly gift. Only + /// avatar outfits and consumables are offered: the 2020.03 + /// exposes + /// TryGetAvatarItemPayload / TryGetConsumableItemDesc + /// but has no equipment payload helper, so equipment gifts aren't + /// assignable on this build. Each option carries the descriptors the + /// watch's ChallengeGift render needs plus the + /// Slug the server grants on completion. + [HttpGet("settings/weekly-challenges/reward-options")] + public async Task GetWeeklyChallengeRewardOptions() + { + var rows = await db.StoreItems + .Where(i => i.IsActive) + .OrderBy(i => i.DisplayName) + .ToListAsync(); + + static object Option( + string kind, + StoreItemEntity item, + string avatarItemDesc = "", + string consumableItemDesc = "") => new + { + Kind = kind, + Label = $"{item.DisplayName} ({item.Slug})", + Slug = item.Slug, + GiftDropId = (int)(item.Id & 0x7fffffff), + AvatarItemDesc = avatarItemDesc, + ConsumableItemDesc = consumableItemDesc, + }; + + var avatarItems = rows + .Select(i => StoreService.TryGetAvatarItemPayload(i.Slug, out _, out var desc) + ? Option("avatar", i, avatarItemDesc: desc) + : null) + .Where(o => o is not null) + .ToArray(); + + var consumables = rows + .Select(i => StoreService.TryGetConsumableItemDesc(i.Slug, out var desc) + ? Option("consumable", i, consumableItemDesc: desc) + : null) + .Where(o => o is not null) + .ToArray(); + + return Ok(new + { + AvatarItems = avatarItems, + Consumables = consumables, + }); + } + + public sealed record WeeklyChallengeSettingsRequest( + bool? CompletedRequired, + List? Challenges, + WeeklyChallengeReward? Reward); + + /// POST api/admin/v1/settings/weekly-challenges — + /// replace the weekly slate + reward. Normalisation (indexing, + /// trimming, the Take(10) cap) happens in + /// . + [HttpPost("settings/weekly-challenges")] + public async Task SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body) + { + var completedRequired = body.CompletedRequired ?? true; + var challenges = body.Challenges ?? ServerSettingsService.DefaultWeeklyChallenges(); + var weekly = await serverSettings.SetWeeklyChallengesAsync(completedRequired, challenges, body.Reward); + await LogAsync("weekly_challenges_updated", "system", 0, ""); + await db.SaveChangesAsync(); + return Ok(weekly); + } + // ── Audit log ──────────────────────────────────────────────────────── [HttpGet("audit")] diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 544cb4b..17069ed 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -22,5 +22,24 @@ public class ServerSettingsEntity /// brand-new account requests are blocked. public bool SignupsDisabled { get; set; } + /// When true, the watch's weekly-challenge map reports + /// CompletedRequired — the player must finish every listed + /// challenge before the gift unlocks. Mirrors + /// ChallengeMap.CompletedRequired on the 2020.03 client. + public bool WeeklyChallengesCompletedRequired { get; set; } = true; + + /// JSON-serialised List<WeeklyChallengeTemplate> + /// (see ServerSettingsService). Empty string falls back to + /// ServerSettingsService.DefaultWeeklyChallenges(). Admin- + /// editable from the SPA Settings page so the weekly slate can change + /// without a redeploy. + public string WeeklyChallengesJson { get; set; } = string.Empty; + + /// JSON-serialised WeeklyChallengeReward — the gift + /// granted when the week's challenges complete (XP + tokens, plus an + /// optional store skin identified by Slug). Empty string falls + /// back to ServerSettingsService.DefaultWeeklyReward(). + public string WeeklyChallengeRewardJson { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 244a82f..8e5af34 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -61,8 +61,8 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | Verb | URL | Status | Handler | |---|---|---|---| -| GET | `api/challenge/v2/getCurrent` | REAL | `ProgressionController.GetChallengeV2` — pulls active challenge for the player from the progression tables | -| POST | `api/challenge/v2/updateProgress` | REAL | `ProgressionController.UpdateChallengeProgress` — writes back delta into progression rows | +| GET | `api/challenge/v2/getCurrent` | REAL | `ProgressionController.CurrentChallenge` — builds the weekly `ChallengeMap` from the admin-configured slate (`ServerSettingsService.GetWeeklyChallengesAsync`); per-player completion comes from progression rows | +| POST | `api/challenge/v2/updateProgress` | REAL | `ProgressionController.UpdateChallengeProgress` — writes back the slot completion; on finishing the week's slate grants the configured reward (XP + tokens, plus the skin/consumable via `StoreService.GrantItemFreeBySlugAsync`) | ### `api/checklist/` diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs index 2eb1eda..b2b066b 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -703,6 +703,20 @@ using (var scope = app.Services.CreateScope()) ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() );"); + // 2026-05-31 — Weekly-challenge config columns on ServerSettings. + // Back the admin SPA's editable weekly slate + gift (XP/tokens + + // an optional store skin granted on completion). EnsureCreated- + // skipped on existing DBs, so add idempotently. + await RunPatchAsync("ServerSettings.WeeklyChallengesCompletedRequired column", + @"ALTER TABLE ""ServerSettings"" + ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;"); + await RunPatchAsync("ServerSettings.WeeklyChallengesJson column", + @"ALTER TABLE ""ServerSettings"" + ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';"); + await RunPatchAsync("ServerSettings.WeeklyChallengeRewardJson column", + @"ALTER TABLE ""ServerSettings"" + ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';"); + // 2026-05-19 — Players.IsCommunityTeam column. Backs the // overhead-badge admin toggle alongside IsDeveloper; the // RoleController ORs the two so either one unlocks the watch's @@ -1238,6 +1252,13 @@ static void ApplySqliteCompatibilityPatches(DorkNetDbContext db) if (!db.Database.IsSqlite()) return; AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse", @"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0"); + // 2026-05-31 — weekly-challenge config columns (see ServerSettingsEntity). + AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired", + @"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1"); + AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson", + @"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''"); + AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson", + @"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''"); } static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition) diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index ca0a102..a0a7ed3 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -1,6 +1,7 @@ using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace DorkNet.Server.Services; @@ -19,6 +20,10 @@ namespace DorkNet.Server.Services; public class ServerSettingsService(DorkNetDbContext db) { private const int RowId = 1; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; public async Task GetAsync() { @@ -36,23 +41,190 @@ public class ServerSettingsService(DorkNetDbContext db) public async Task SetSignupsDisabledAsync(bool disabled) { - var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId); - if (existing is null) - { - existing = new ServerSettingsEntity - { - Id = RowId, - SignupsDisabled = disabled, - UpdatedAt = DateTime.UtcNow, - }; - db.ServerSettings.Add(existing); - } - else - { - existing.SignupsDisabled = disabled; - existing.UpdatedAt = DateTime.UtcNow; - } + var existing = await GetTrackedRowAsync(); + existing.SignupsDisabled = disabled; + existing.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return existing; } + + public async Task GetWeeklyChallengesAsync() + { + var row = await GetAsync(); + return ToWeeklySettings(row); + } + + public async Task SetWeeklyChallengesAsync( + bool completedRequired, + IReadOnlyList challenges, + WeeklyChallengeReward? reward) + { + var normalized = NormalizeWeeklyChallenges(challenges); + var normalizedReward = NormalizeWeeklyReward(reward); + var existing = await GetTrackedRowAsync(); + existing.WeeklyChallengesCompletedRequired = completedRequired; + existing.WeeklyChallengesJson = JsonSerializer.Serialize(normalized, JsonOptions); + existing.WeeklyChallengeRewardJson = JsonSerializer.Serialize(normalizedReward, JsonOptions); + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return ToWeeklySettings(existing); + } + + private async Task GetTrackedRowAsync() + { + var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId); + if (existing is not null) return existing; + + existing = new ServerSettingsEntity { Id = RowId, UpdatedAt = DateTime.UtcNow }; + db.ServerSettings.Add(existing); + return existing; + } + + private static WeeklyChallengeSettings ToWeeklySettings(ServerSettingsEntity row) + { + var challenges = DefaultWeeklyChallenges(); + var reward = DefaultWeeklyReward(); + if (!string.IsNullOrWhiteSpace(row.WeeklyChallengesJson)) + { + try + { + var parsed = JsonSerializer.Deserialize>( + row.WeeklyChallengesJson, + JsonOptions); + if (parsed?.Count > 0) + challenges = NormalizeWeeklyChallenges(parsed); + } + catch + { + challenges = DefaultWeeklyChallenges(); + } + } + + if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson)) + { + try + { + var parsed = JsonSerializer.Deserialize( + row.WeeklyChallengeRewardJson, + JsonOptions); + reward = NormalizeWeeklyReward(parsed); + } + catch + { + reward = DefaultWeeklyReward(); + } + } + + return new WeeklyChallengeSettings( + row.WeeklyChallengesCompletedRequired, + challenges, + reward, + row.UpdatedAt); + } + + private static List NormalizeWeeklyChallenges( + IReadOnlyList challenges) + { + var normalized = challenges + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .Take(10) + .Select((c, i) => new WeeklyChallengeTemplate( + i, + c.Name.Trim(), + string.IsNullOrWhiteSpace(c.Config) ? "{}" : c.Config.Trim(), + c.Description?.Trim() ?? string.Empty, + c.Tooltip?.Trim() ?? string.Empty)) + .ToList(); + + return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges(); + } + + private static WeeklyChallengeReward NormalizeWeeklyReward(WeeklyChallengeReward? reward) + { + reward ??= DefaultWeeklyReward(); + return new WeeklyChallengeReward( + GiftDropId: Math.Max(0, reward.GiftDropId), + Slug: reward.Slug?.Trim() ?? string.Empty, + Xp: Math.Max(0, reward.Xp), + Tokens: Math.Max(0, reward.Tokens), + Level: Math.Max(0, reward.Level), + GiftContext: Math.Max(0, reward.GiftContext), + GiftRarity: Math.Max(0, reward.GiftRarity), + AvatarItemDesc: reward.AvatarItemDesc?.Trim() ?? string.Empty, + ConsumableItemDesc: reward.ConsumableItemDesc?.Trim() ?? string.Empty, + EquipmentPrefabName: reward.EquipmentPrefabName?.Trim() ?? string.Empty, + EquipmentModificationGuid: reward.EquipmentModificationGuid?.Trim() ?? string.Empty); + } + + public static List DefaultWeeklyChallenges() => + [ + new( + Index: 0, + Name: "Visit any room", + Config: "{\"Type\":\"VisitRooms\",\"Goal\":1}", + Description: "Step into any room this week.", + Tooltip: "Any room visit counts toward this challenge."), + new( + Index: 1, + Name: "Cheer a player", + Config: "{\"Type\":\"CheerPlayers\",\"Goal\":1}", + Description: "Send a cheer to anyone you meet this week.", + Tooltip: "Use the watch to cheer players you meet."), + new( + Index: 2, + Name: "Finish an activity", + Config: "{\"Type\":\"CompleteActivity\",\"Goal\":1}", + Description: "Complete any activity this week.", + Tooltip: "Play any activity to make progress."), + ]; + + public static WeeklyChallengeReward DefaultWeeklyReward() => + new( + GiftDropId: 0, + Slug: string.Empty, + Xp: 250, + Tokens: 250, + Level: 1, + GiftContext: 0, + GiftRarity: 0, + AvatarItemDesc: string.Empty, + ConsumableItemDesc: string.Empty, + EquipmentPrefabName: string.Empty, + EquipmentModificationGuid: string.Empty); } + +public sealed record WeeklyChallengeSettings( + bool CompletedRequired, + IReadOnlyList Challenges, + WeeklyChallengeReward Reward, + DateTime UpdatedAt); + +public sealed record WeeklyChallengeTemplate( + int Index, + string Name, + string Config, + string Description, + string Tooltip); + +/// The gift granted when the week's challenges complete. +/// Skin grant (the fix vs. the December build): December +/// stored only a masked GiftDropId and could never recover the +/// store item, so avatar/consumable rewards were shown on the watch but +/// never actually landed in the player's inventory. March carries the +/// item's so ProgressionController can grant +/// the real item server-side via +/// . The +/// AvatarItemDesc/ConsumableItemDesc/Equipment* +/// fields still ride the wire for the client's gift-card render. +public sealed record WeeklyChallengeReward( + int GiftDropId, + string Slug, + int Xp, + int Tokens, + int Level, + int GiftContext, + int GiftRarity, + string AvatarItemDesc, + string ConsumableItemDesc, + string EquipmentPrefabName, + string EquipmentModificationGuid); diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 775ddfd..363aa68 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -640,6 +640,62 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio return new(true, null, newBalance, item.Slug); } + /// Grant a store item to a player's inventory for free — + /// the same inventory-append path as but + /// with no balance check and no currency deduction. Backs + /// weekly-challenge skin rewards: the challenge reward stores the + /// item and this lands the real item once the + /// week's challenges complete. Idempotent — re-granting an owned + /// avatar item is a no-op; a consumable bumps quantity. Returns true + /// when something new (or another consumable) was added. + public async Task GrantItemFreeBySlugAsync(long playerId, string slug) + { + if (string.IsNullOrWhiteSpace(slug)) return false; + var item = await GetBySlugAsync(slug); + if (item is null || !item.IsActive) return false; + + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == playerId); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = playerId }; + db.Avatars.Add(avatar); + } + + if (TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc)) + { + var guid = InventoryAvatarItemDesc(avatarItemDesc); + var ownedGuids = ParseWardrobeInventory(avatar.InventoryJson); + if (ownedGuids.Contains(guid, StringComparer.OrdinalIgnoreCase)) + return false; + ownedGuids.Add(guid); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(ownedGuids); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return true; + } + + var inventory = ParseInventory(avatar.InventoryJson); + var existing = inventory.FirstOrDefault(e => e.ItemId == item.Slug); + if (existing is not null) + { + if (string.Equals(item.Category, "consumable", StringComparison.OrdinalIgnoreCase)) + { + existing.Quantity += 1; + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return true; + } + return false; + } + + inventory.Add(new InventoryEntry { ItemId = item.Slug, Quantity = 1 }); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return true; + } + private static List ParseWardrobeInventory(string? json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index b39da41..4b767bc 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -2,23 +2,84 @@ import { useEffect, useState } from 'react'; import { api, get } from '../lib/api'; import { PageHeader } from '../components/PageHeader'; import { useToast } from '../components/Toast'; -import { RefreshCw } from '../components/Icons'; +import { Plus, RefreshCw, Trash } from '../components/Icons'; interface ServerSettings { signupsDisabled: boolean; + weeklyChallengesCompletedRequired: boolean; updatedAt: string; } +interface WeeklyChallenge { + index: number; + name: string; + config: string; + description: string; + tooltip: string; +} + +interface WeeklyReward { + giftDropId: number; + slug: string; + xp: number; + tokens: number; + level: number; + giftContext: number; + giftRarity: number; + avatarItemDesc: string; + consumableItemDesc: string; + equipmentPrefabName: string; + equipmentModificationGuid: string; +} + +interface WeeklyChallengeSettings { + completedRequired: boolean; + challenges: WeeklyChallenge[]; + reward: WeeklyReward; + updatedAt: string; +} + +// The 2020.03 StoreService exposes avatar-outfit + consumable payloads +// only (no equipment helper), so the reward picker offers those two +// kinds. Each option carries the item `slug` — the reliable handle the +// server grants on completion. (The December build keyed rewards off the +// descriptor strings alone and never actually landed the item.) +interface RewardOption { + kind: 'avatar' | 'consumable'; + label: string; + slug: string; + giftDropId: number; + avatarItemDesc: string; + consumableItemDesc: string; +} + +interface RewardOptions { + avatarItems: RewardOption[]; + consumables: RewardOption[]; +} + export function Settings() { const [settings, setSettings] = useState(null); + const [weekly, setWeekly] = useState(null); + const [rewardOptions, setRewardOptions] = useState({ + avatarItems: [], + consumables: [], + }); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); + const [weeklyBusy, setWeeklyBusy] = useState(false); const toast = useToast(); const load = async () => { try { - const s = await get('/settings'); + const [s, w, rewards] = await Promise.all([ + get('/settings'), + get('/settings/weekly-challenges'), + get('/settings/weekly-challenges/reward-options'), + ]); setSettings(s); + setWeekly(w); + setRewardOptions(rewards); setErr(null); } catch (e) { setErr((e as Error).message); @@ -47,6 +108,151 @@ export function Settings() { } }; + const updateWeeklyChallenge = (index: number, patch: Partial) => { + setWeekly((current) => { + if (!current) return current; + return { + ...current, + challenges: current.challenges.map((challenge, i) => + i === index ? { ...challenge, ...patch } : challenge), + }; + }); + }; + + const addWeeklyChallenge = () => { + setWeekly((current) => { + if (!current) return current; + return { + ...current, + challenges: [ + ...current.challenges, + { + index: current.challenges.length, + name: 'New weekly challenge', + config: '{}', + description: '', + tooltip: '', + }, + ], + }; + }); + }; + + const removeWeeklyChallenge = (index: number) => { + setWeekly((current) => { + if (!current) return current; + const next = current.challenges.filter((_, i) => i !== index); + return { ...current, challenges: next.map((challenge, i) => ({ ...challenge, index: i })) }; + }); + }; + + const updateWeeklyReward = (patch: Partial) => { + setWeekly((current) => current ? { + ...current, + reward: { ...current.reward, ...patch }, + } : current); + }; + + const rewardNumber = (key: keyof WeeklyReward, value: string) => { + const n = Number.parseInt(value, 10); + updateWeeklyReward({ [key]: Number.isFinite(n) ? Math.max(0, n) : 0 } as Partial); + }; + + const allRewardOptions: RewardOption[] = [ + ...rewardOptions.avatarItems, + ...rewardOptions.consumables, + ]; + + // Selection is keyed purely on the item slug — the one handle that + // round-trips through save/grant. A configured reward whose slug no + // longer matches an active store item falls back to "current" so the + // admin can see (and re-pick) it. + const selectedRewardValue = weekly + ? (weekly.reward.slug + ? (allRewardOptions.some((o) => o.slug === weekly.reward.slug) + ? `slug:${weekly.reward.slug}` + : 'current') + : 'none') + : 'none'; + + const selectedRewardOption = selectedRewardValue === 'current' + ? weekly?.reward ?? null + : allRewardOptions.find((o) => `slug:${o.slug}` === selectedRewardValue) ?? null; + + const selectWeeklyReward = (value: string) => { + if (value === 'none') { + updateWeeklyReward({ + giftDropId: 0, + slug: '', + avatarItemDesc: '', + consumableItemDesc: '', + equipmentPrefabName: '', + equipmentModificationGuid: '', + }); + return; + } + if (value === 'current') return; + + const selected = allRewardOptions.find((o) => `slug:${o.slug}` === value); + if (!selected) return; + updateWeeklyReward({ + giftDropId: selected.giftDropId, + slug: selected.slug, + avatarItemDesc: selected.avatarItemDesc, + consumableItemDesc: selected.consumableItemDesc, + equipmentPrefabName: '', + equipmentModificationGuid: '', + }); + }; + + const resetWeeklyDefaults = async () => { + if (!confirm('Reset weekly challenges to the built-in defaults?')) return; + setWeeklyBusy(true); + try { + const updated = await api('/settings/weekly-challenges', { + method: 'POST', + body: { CompletedRequired: true, Challenges: [] }, + }); + setWeekly(updated); + setSettings((current) => current ? { + ...current, + weeklyChallengesCompletedRequired: updated.completedRequired, + updatedAt: updated.updatedAt, + } : current); + toast.push('Weekly challenges reset.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setWeeklyBusy(false); + } + }; + + const saveWeeklyChallenges = async () => { + if (!weekly) return; + setWeeklyBusy(true); + try { + const updated = await api('/settings/weekly-challenges', { + method: 'POST', + body: { + CompletedRequired: weekly.completedRequired, + Challenges: weekly.challenges, + Reward: weekly.reward, + }, + }); + setWeekly(updated); + setSettings((current) => current ? { + ...current, + weeklyChallengesCompletedRequired: updated.completedRequired, + updatedAt: updated.updatedAt, + } : current); + toast.push('Weekly challenges saved.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setWeeklyBusy(false); + } + }; + return (
{err}
)} - {!settings && !err && ( + {!settings && !weekly && !err && (
Loading…
)} @@ -96,6 +302,187 @@ export function Settings() { )} + + {weekly && ( +
+
+
+

Weekly challenges

+

+ These are sent to the watch as the current challenge map. Players start each weekly challenge incomplete until their own progress is reported. When the week's challenges complete, the reward item is granted straight to the player's inventory. +

+

+ Last changed {new Date(weekly.updatedAt).toLocaleString()}. +

+
+
+ + + +
+
+ + + +
+
+

Weekly reward

+ {weekly.reward.xp} XP · {weekly.reward.tokens} tokens +
+
+ + + + + + + {selectedRewardOption && selectedRewardValue !== 'none' && ( +
+
+ {selectedRewardOption.avatarItemDesc && `avatar: ${selectedRewardOption.avatarItemDesc}`} + {selectedRewardOption.consumableItemDesc && `consumable: ${selectedRewardOption.consumableItemDesc}`} +
+
+ )} +
+
+ +
+ {weekly.challenges.map((challenge, index) => ( +
+
+ Challenge {index + 1} + +
+
+ + + +