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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user