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
+56
View File
@@ -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();