mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user