mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
weekly challenges: admin-configurable slate + working skin rewards
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.
This commit is contained in:
@@ -18,7 +18,11 @@ namespace DorkNet.Server.Controllers.API.ProgressionApi;
|
||||
/// <c>obj:{group}:{index}</c>.
|
||||
/// </summary>
|
||||
[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<string, bool>();
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Project a <see cref="WeeklyChallengeReward"/> onto the
|
||||
/// 2020.03 <c>ChallengeGift</c> wire shape (dump.cs ChallengeGift,
|
||||
/// TypeDefIndex 11455). <c>AvatarItemType</c> is sent as
|
||||
/// <c>Outfit</c> (0) whenever an <c>AvatarItemDesc</c> is present and
|
||||
/// <c>null</c> otherwise — the client's gift-card render binds the
|
||||
/// avatar preview through it, and an <c>AvatarItemDesc</c> with no
|
||||
/// <c>AvatarItemType</c> leaves that binding half-populated (same
|
||||
/// failure mode StoreService documents for the shop grid).</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>AvatarItemType.Outfit — kept in sync with
|
||||
/// <c>StoreService</c>'s constant of the same value.</summary>
|
||||
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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings/weekly-challenges</c> — the
|
||||
/// current weekly slate + reward the watch's challenge map is built
|
||||
/// from (<see cref="ProgressionApi.ProgressionController"/>).</summary>
|
||||
[HttpGet("settings/weekly-challenges")]
|
||||
public async Task<ActionResult> GetWeeklyChallenges()
|
||||
{
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
return Ok(weekly);
|
||||
}
|
||||
|
||||
/// <summary>GET
|
||||
/// <c>api/admin/v1/settings/weekly-challenges/reward-options</c> —
|
||||
/// the store items that can be assigned as the weekly gift. Only
|
||||
/// avatar outfits and consumables are offered: the 2020.03
|
||||
/// <see cref="StoreService"/> exposes
|
||||
/// <c>TryGetAvatarItemPayload</c> / <c>TryGetConsumableItemDesc</c>
|
||||
/// but has no equipment payload helper, so equipment gifts aren't
|
||||
/// assignable on this build. Each option carries the descriptors the
|
||||
/// watch's <c>ChallengeGift</c> render needs plus the
|
||||
/// <c>Slug</c> the server grants on completion.</summary>
|
||||
[HttpGet("settings/weekly-challenges/reward-options")]
|
||||
public async Task<ActionResult> 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<WeeklyChallengeTemplate>? Challenges,
|
||||
WeeklyChallengeReward? Reward);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/settings/weekly-challenges</c> —
|
||||
/// replace the weekly slate + reward. Normalisation (indexing,
|
||||
/// trimming, the Take(10) cap) happens in
|
||||
/// <see cref="ServerSettingsService.SetWeeklyChallengesAsync"/>.</summary>
|
||||
[HttpPost("settings/weekly-challenges")]
|
||||
public async Task<ActionResult> 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")]
|
||||
|
||||
@@ -22,5 +22,24 @@ public class ServerSettingsEntity
|
||||
/// brand-new account requests are blocked.</summary>
|
||||
public bool SignupsDisabled { get; set; }
|
||||
|
||||
/// <summary>When true, the watch's weekly-challenge map reports
|
||||
/// <c>CompletedRequired</c> — the player must finish every listed
|
||||
/// challenge before the gift unlocks. Mirrors
|
||||
/// <c>ChallengeMap.CompletedRequired</c> on the 2020.03 client.</summary>
|
||||
public bool WeeklyChallengesCompletedRequired { get; set; } = true;
|
||||
|
||||
/// <summary>JSON-serialised <c>List<WeeklyChallengeTemplate></c>
|
||||
/// (see <c>ServerSettingsService</c>). Empty string falls back to
|
||||
/// <c>ServerSettingsService.DefaultWeeklyChallenges()</c>. Admin-
|
||||
/// editable from the SPA Settings page so the weekly slate can change
|
||||
/// without a redeploy.</summary>
|
||||
public string WeeklyChallengesJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON-serialised <c>WeeklyChallengeReward</c> — the gift
|
||||
/// granted when the week's challenges complete (XP + tokens, plus an
|
||||
/// optional store skin identified by <c>Slug</c>). Empty string falls
|
||||
/// back to <c>ServerSettingsService.DefaultWeeklyReward()</c>.</summary>
|
||||
public string WeeklyChallengeRewardJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ServerSettingsEntity> GetAsync()
|
||||
{
|
||||
@@ -36,23 +41,190 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
|
||||
public async Task<ServerSettingsEntity> 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<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToWeeklySettings(row);
|
||||
}
|
||||
|
||||
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
|
||||
bool completedRequired,
|
||||
IReadOnlyList<WeeklyChallengeTemplate> 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<ServerSettingsEntity> 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<List<WeeklyChallengeTemplate>>(
|
||||
row.WeeklyChallengesJson,
|
||||
JsonOptions);
|
||||
if (parsed?.Count > 0)
|
||||
challenges = NormalizeWeeklyChallenges(parsed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
challenges = DefaultWeeklyChallenges();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<WeeklyChallengeReward>(
|
||||
row.WeeklyChallengeRewardJson,
|
||||
JsonOptions);
|
||||
reward = NormalizeWeeklyReward(parsed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
reward = DefaultWeeklyReward();
|
||||
}
|
||||
}
|
||||
|
||||
return new WeeklyChallengeSettings(
|
||||
row.WeeklyChallengesCompletedRequired,
|
||||
challenges,
|
||||
reward,
|
||||
row.UpdatedAt);
|
||||
}
|
||||
|
||||
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
|
||||
IReadOnlyList<WeeklyChallengeTemplate> 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<WeeklyChallengeTemplate> 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<WeeklyChallengeTemplate> Challenges,
|
||||
WeeklyChallengeReward Reward,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record WeeklyChallengeTemplate(
|
||||
int Index,
|
||||
string Name,
|
||||
string Config,
|
||||
string Description,
|
||||
string Tooltip);
|
||||
|
||||
/// <summary>The gift granted when the week's challenges complete.
|
||||
/// <para><b>Skin grant (the fix vs. the December build):</b> December
|
||||
/// stored only a masked <c>GiftDropId</c> 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 <see cref="Slug"/> so <c>ProgressionController</c> can grant
|
||||
/// the real item server-side via
|
||||
/// <see cref="StoreService.GrantItemFreeBySlugAsync"/>. The
|
||||
/// <c>AvatarItemDesc</c>/<c>ConsumableItemDesc</c>/<c>Equipment*</c>
|
||||
/// fields still ride the wire for the client's gift-card render.</para></summary>
|
||||
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);
|
||||
|
||||
@@ -640,6 +640,62 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
return new(true, null, newBalance, item.Slug);
|
||||
}
|
||||
|
||||
/// <summary>Grant a store item to a player's inventory for free —
|
||||
/// the same inventory-append path as <see cref="PurchaseAsync"/> but
|
||||
/// with no balance check and no currency deduction. Backs
|
||||
/// weekly-challenge skin rewards: the challenge reward stores the
|
||||
/// item <paramref name="slug"/> 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.</summary>
|
||||
public async Task<bool> 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<string> ParseWardrobeInventory(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||
|
||||
@@ -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<ServerSettings | null>(null);
|
||||
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
|
||||
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
|
||||
avatarItems: [],
|
||||
consumables: [],
|
||||
});
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [weeklyBusy, setWeeklyBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await get<ServerSettings>('/settings');
|
||||
const [s, w, rewards] = await Promise.all([
|
||||
get<ServerSettings>('/settings'),
|
||||
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
|
||||
get<RewardOptions>('/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<WeeklyChallenge>) => {
|
||||
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<WeeklyReward>) => {
|
||||
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<WeeklyReward>);
|
||||
};
|
||||
|
||||
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<WeeklyChallengeSettings>('/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<WeeklyChallengeSettings>('/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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -64,7 +270,7 @@ export function Settings() {
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||
)}
|
||||
|
||||
{!settings && !err && (
|
||||
{!settings && !weekly && !err && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
|
||||
)}
|
||||
|
||||
@@ -96,6 +302,187 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weekly && (
|
||||
<div className="card !p-5 max-w-5xl mt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Weekly challenges</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-ink-500">
|
||||
Last changed {new Date(weekly.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={addWeeklyChallenge} disabled={weeklyBusy} className="btn-secondary text-xs">
|
||||
<Plus />
|
||||
Add
|
||||
</button>
|
||||
<button onClick={resetWeeklyDefaults} disabled={weeklyBusy} className="btn-secondary text-xs">
|
||||
<RefreshCw />
|
||||
Defaults
|
||||
</button>
|
||||
<button onClick={saveWeeklyChallenges} disabled={weeklyBusy} className="btn-primary text-xs">
|
||||
{weeklyBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-5 flex items-center gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={weekly.completedRequired}
|
||||
onChange={(e) => setWeekly({ ...weekly, completedRequired: e.target.checked })}
|
||||
className="size-4 rounded border-ink-700 bg-ink-900 text-brand-500 focus:ring-brand-500/30"
|
||||
/>
|
||||
CompletedRequired
|
||||
</label>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-ink-800 bg-ink-950/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-ink-50">Weekly reward</h3>
|
||||
<span className="badge-admin">{weekly.reward.xp} XP · {weekly.reward.tokens} tokens</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="label">XP</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min={0}
|
||||
value={weekly.reward.xp}
|
||||
onChange={(e) => rewardNumber('xp', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Tokens</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min={0}
|
||||
value={weekly.reward.tokens}
|
||||
onChange={(e) => rewardNumber('tokens', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Level</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min={0}
|
||||
value={weekly.reward.level}
|
||||
onChange={(e) => rewardNumber('level', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Gift context</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min={0}
|
||||
value={weekly.reward.giftContext}
|
||||
onChange={(e) => rewardNumber('giftContext', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Gift rarity</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min={0}
|
||||
value={weekly.reward.giftRarity}
|
||||
onChange={(e) => rewardNumber('giftRarity', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block md:col-span-3">
|
||||
<span className="label">Reward item</span>
|
||||
<select
|
||||
className="input mt-1"
|
||||
value={selectedRewardValue}
|
||||
onChange={(e) => selectWeeklyReward(e.target.value)}
|
||||
>
|
||||
<option value="none">None (XP + tokens only)</option>
|
||||
{selectedRewardValue === 'current' && (
|
||||
<option value="current">Current item ({weekly.reward.slug || 'unknown'})</option>
|
||||
)}
|
||||
<optgroup label="Avatar items">
|
||||
{rewardOptions.avatarItems.map((option) => (
|
||||
<option key={`slug:${option.slug}`} value={`slug:${option.slug}`}>{option.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Consumables">
|
||||
{rewardOptions.consumables.map((option) => (
|
||||
<option key={`slug:${option.slug}`} value={`slug:${option.slug}`}>{option.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
{selectedRewardOption && selectedRewardValue !== 'none' && (
|
||||
<div className="md:col-span-3 rounded border border-ink-800 bg-ink-950/40 px-3 py-2 text-[11px] text-ink-400">
|
||||
<div className="font-mono break-all">
|
||||
{selectedRewardOption.avatarItemDesc && `avatar: ${selectedRewardOption.avatarItemDesc}`}
|
||||
{selectedRewardOption.consumableItemDesc && `consumable: ${selectedRewardOption.consumableItemDesc}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
{weekly.challenges.map((challenge, index) => (
|
||||
<div key={index} className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="badge-neutral">Challenge {index + 1}</span>
|
||||
<button
|
||||
onClick={() => removeWeeklyChallenge(index)}
|
||||
disabled={weeklyBusy || weekly.challenges.length <= 1}
|
||||
className="btn-ghost !px-2 !py-1 text-danger"
|
||||
title="Remove challenge"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">Name</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={challenge.name}
|
||||
onChange={(e) => updateWeeklyChallenge(index, { name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Tooltip</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={challenge.tooltip}
|
||||
onChange={(e) => updateWeeklyChallenge(index, { tooltip: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="label">Description</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={challenge.description}
|
||||
onChange={(e) => updateWeeklyChallenge(index, { description: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="label">Config</span>
|
||||
<textarea
|
||||
className="input mt-1 min-h-24 font-mono text-xs"
|
||||
value={challenge.config}
|
||||
onChange={(e) => updateWeeklyChallenge(index, { config: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,16 @@ Subsystems **NOT** present on this branch (these landed in December):
|
||||
- Clubs surface
|
||||
- Playlists (curated + user-created)
|
||||
- Group DMs / chat threads
|
||||
- Weekly challenges
|
||||
- Room keys (purchasable room access)
|
||||
- Per-room co-owner / moderator / host roles
|
||||
- Late-2020 store catalog additions
|
||||
|
||||
Weekly challenges **are** present on this branch: the admin SPA's
|
||||
**Server settings** page edits the weekly slate, the `CompletedRequired`
|
||||
flag, and the gift (XP + tokens, plus an optional store skin/consumable
|
||||
that's granted straight to the player's inventory when the week's
|
||||
challenges complete).
|
||||
|
||||
If you need any of those, you're on the wrong branch — see
|
||||
[`december-2020-12-18`](../../tree/december-2020-12-18).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user