feat: add XP level system with resolveLevel utility
- Add resolveLevel utility to compute level name, progress, and XP thresholds - Add seedExperience script for populating experience levels - Update XPDisplay to show level name, progress bar, and percentage to next level - Update layout to fetch experience levels and pass resolved XP data to sidebar
This commit is contained in:
parent
2ffd5e2a84
commit
98e171d088
5 changed files with 249 additions and 20 deletions
|
|
@ -7,6 +7,7 @@ import config from "@payload-config";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import { SiteHeader } from "@/components/frontend/SiteHeader";
|
import { SiteHeader } from "@/components/frontend/SiteHeader";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import { resolveLevel, type ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
description: "A gamified platform for Polaris Task Force, an Arma 3 unit.",
|
description: "A gamified platform for Polaris Task Force, an Arma 3 unit.",
|
||||||
|
|
@ -22,23 +23,39 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
let userXp = 0;
|
let xpLevel: ResolvedLevel = {
|
||||||
|
levelName: "Recruit",
|
||||||
|
progress: 0,
|
||||||
|
currentLevelXP: 0,
|
||||||
|
nextLevelXP: null,
|
||||||
|
xp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const profileRes = await payload.find({
|
const [profileRes, experienceRes] = await Promise.all([
|
||||||
|
payload.find({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
where: {
|
where: { user: { equals: user.id } },
|
||||||
user: {
|
|
||||||
equals: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
progression: {
|
progression: {
|
||||||
awards: true,
|
awards: true,
|
||||||
xp: true,
|
xp: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
payload.find({
|
||||||
|
collection: "experience",
|
||||||
|
sort: "requiredPoints",
|
||||||
|
limit: 100,
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
requiredPoints: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
||||||
|
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -50,7 +67,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
displayName: user?.payloadDisplayName ?? "Guest",
|
displayName: user?.payloadDisplayName ?? "Guest",
|
||||||
username: user?.username ?? "Not logged in",
|
username: user?.username ?? "Not logged in",
|
||||||
}}
|
}}
|
||||||
xp={userXp}
|
xpLevel={xpLevel}
|
||||||
adminUrl={payloadConfig.routes.admin}
|
adminUrl={payloadConfig.routes.admin}
|
||||||
className="border-r"
|
className="border-r"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,31 @@
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { SparkleIcon } from "lucide-react";
|
import { SparkleIcon } from "lucide-react";
|
||||||
|
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
|
||||||
|
const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
||||||
|
const { levelName, progress, xp, nextLevelXP } = xpLevel;
|
||||||
|
|
||||||
const XPDisplay = ({ xp }: { xp: number }) => {
|
|
||||||
return (
|
return (
|
||||||
<Item>
|
<Item>
|
||||||
<ItemHeader>
|
<ItemHeader>
|
||||||
<ItemTitle className="flex justify-between items-center w-full">
|
<ItemTitle className="flex justify-between items-center w-full">
|
||||||
<SparkleIcon />
|
<SparkleIcon />
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="uppercase">Rookie</span>
|
<span className="uppercase">{levelName}</span>
|
||||||
<span className="text-2xl font-bold uppercase">{xp ?? 0}</span>
|
<span className="text-2xl font-bold uppercase">
|
||||||
|
{xp.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent className="flex flex-col gap-1">
|
||||||
<Progress value={33} />
|
<Progress value={progress} />
|
||||||
|
{nextLevelXP !== null && (
|
||||||
|
<span className="text-[10px] text-muted-foreground text-right">
|
||||||
|
{Math.floor(progress)}% to next level
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { seedRanks } from "@/tools/seed/seedRanks";
|
import { seedRanks } from "@/tools/seed/seedRanks";
|
||||||
import { seedUsers } from "@/tools/seed/seedUsers";
|
import { seedUsers } from "@/tools/seed/seedUsers";
|
||||||
import { seedFactions } from "@/tools/seed/seedFactions";
|
import { seedFactions } from "@/tools/seed/seedFactions";
|
||||||
|
import { seedExperience } from "@/tools/seed/seedExperience";
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
await seedUsers();
|
await seedUsers();
|
||||||
await seedRanks();
|
await seedRanks();
|
||||||
|
await seedExperience();
|
||||||
|
|
||||||
// Intelligence
|
// Intelligence
|
||||||
await seedFactions();
|
await seedFactions();
|
||||||
|
|
|
||||||
132
src/tools/seed/seedExperience.ts
Normal file
132
src/tools/seed/seedExperience.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import config from "@payload-config";
|
||||||
|
|
||||||
|
function richText(text: string) {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
type: "root",
|
||||||
|
format: "" as const,
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
format: "" as const,
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
format: 0,
|
||||||
|
style: "",
|
||||||
|
mode: "normal" as const,
|
||||||
|
text,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_XP_LEVELS = [
|
||||||
|
{
|
||||||
|
name: "Recruit",
|
||||||
|
requiredPoints: 0,
|
||||||
|
description: richText(
|
||||||
|
"Fresh out of boot camp. Everything is new — follow orders and learn the ropes.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Operative",
|
||||||
|
requiredPoints: 500,
|
||||||
|
description: richText(
|
||||||
|
"Proven capable in the field. Trusted with standard combat operations.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Veteran",
|
||||||
|
requiredPoints: 1_500,
|
||||||
|
description: richText(
|
||||||
|
"Battle-hardened and dependable. Seen enough action to brief newcomers.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Specialist",
|
||||||
|
requiredPoints: 3_500,
|
||||||
|
description: richText(
|
||||||
|
"Expert in a specialized role. Called upon for high-priority missions.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Elite",
|
||||||
|
requiredPoints: 7_000,
|
||||||
|
description: richText(
|
||||||
|
"Among the best in the unit. Operates with minimal oversight in hostile territory.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadow",
|
||||||
|
requiredPoints: 12_000,
|
||||||
|
description: richText(
|
||||||
|
"A ghost on the battlefield. Few operators reach this level of proficiency.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vanguard",
|
||||||
|
requiredPoints: 20_000,
|
||||||
|
description: richText(
|
||||||
|
"Leads from the front. Sets the standard for the entire task force.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Phantom",
|
||||||
|
requiredPoints: 35_000,
|
||||||
|
description: richText(
|
||||||
|
"Operates beyond the wire. Reputation alone shifts the odds.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Apex",
|
||||||
|
requiredPoints: 55_000,
|
||||||
|
description: richText(
|
||||||
|
"The peak of combat effectiveness. A living legend within the unit.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mythic",
|
||||||
|
requiredPoints: 80_000,
|
||||||
|
description: richText(
|
||||||
|
"Status spoken of in hushed tones. Their record is the unit's history.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedExperience = async () => {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
payload.logger.info("Seeding XP level data...");
|
||||||
|
|
||||||
|
payload.logger.warn("Deleting all existing XP levels...");
|
||||||
|
await payload.delete({
|
||||||
|
collection: "experience",
|
||||||
|
where: {
|
||||||
|
name: { exists: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
payload.logger.info(`Creating ${DEFAULT_XP_LEVELS.length} new XP levels...`);
|
||||||
|
for (const level of DEFAULT_XP_LEVELS) {
|
||||||
|
const result = await payload.create({
|
||||||
|
collection: "experience",
|
||||||
|
data: level,
|
||||||
|
});
|
||||||
|
|
||||||
|
payload.logger.info(`XP Level '${result.name}' (${result.requiredPoints} XP) created.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await seedExperience();
|
||||||
68
src/utils/xp/resolveLevel.ts
Normal file
68
src/utils/xp/resolveLevel.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
export type ExperienceLevel = {
|
||||||
|
name: string;
|
||||||
|
requiredPoints: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedLevel = {
|
||||||
|
levelName: string;
|
||||||
|
progress: number;
|
||||||
|
currentLevelXP: number;
|
||||||
|
nextLevelXP: number | null;
|
||||||
|
xp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a user's XP and a sorted (ascending by requiredPoints) list of
|
||||||
|
* experience levels, returns the current bracket name and progress %.
|
||||||
|
*
|
||||||
|
* The progress bar represents how far the user has advanced within their
|
||||||
|
* current bracket: (xp - currentLevelXP) / (nextLevelXP - currentLevelXP).
|
||||||
|
* At the max level the bar sits at 100%.
|
||||||
|
*/
|
||||||
|
export function resolveLevel(
|
||||||
|
xp: number,
|
||||||
|
levels: ExperienceLevel[],
|
||||||
|
): ResolvedLevel {
|
||||||
|
if (levels.length === 0) {
|
||||||
|
return {
|
||||||
|
levelName: "Unknown",
|
||||||
|
progress: 0,
|
||||||
|
currentLevelXP: 0,
|
||||||
|
nextLevelXP: null,
|
||||||
|
xp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...levels].sort((a, b) => a.requiredPoints - b.requiredPoints);
|
||||||
|
|
||||||
|
// Find the highest level the user has reached
|
||||||
|
let currentIdx = 0;
|
||||||
|
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||||
|
if (xp >= sorted[i].requiredPoints) {
|
||||||
|
currentIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = sorted[currentIdx];
|
||||||
|
const next = sorted[currentIdx + 1] ?? null;
|
||||||
|
|
||||||
|
const currentLevelXP = current.requiredPoints;
|
||||||
|
const nextLevelXP = next?.requiredPoints ?? null;
|
||||||
|
|
||||||
|
let progress: number;
|
||||||
|
if (nextLevelXP === null) {
|
||||||
|
progress = 100;
|
||||||
|
} else {
|
||||||
|
const range = nextLevelXP - currentLevelXP;
|
||||||
|
progress = range > 0 ? ((xp - currentLevelXP) / range) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelName: current.name,
|
||||||
|
progress: Math.min(Math.max(progress, 0), 100),
|
||||||
|
currentLevelXP,
|
||||||
|
nextLevelXP,
|
||||||
|
xp,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue