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:
Alexa
2026-05-31 14:47:12 +03:00
parent 1eba8d9345
commit f6a4fdc5fb
9 changed files with 858 additions and 58 deletions
@@ -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")]