1
0
Fork 0

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:
Jason Fraley 2026-07-31 20:01:23 -04:00
parent 2ffd5e2a84
commit 98e171d088
5 changed files with 249 additions and 20 deletions

View file

@ -7,6 +7,7 @@ import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { SiteHeader } from "@/components/frontend/SiteHeader";
import { Metadata } from "next";
import { resolveLevel, type ResolvedLevel } from "@/utils/xp/resolveLevel";
export const metadata: Metadata = {
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 { 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) {
const profileRes = await payload.find({
collection: "profiles",
where: {
user: {
equals: user.id,
const [profileRes, experienceRes] = await Promise.all([
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
progression: {
awards: true,
xp: true,
},
},
},
select: {
progression: {
awards: true,
xp: true,
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
requiredPoints: true,
},
},
});
userXp = profileRes.docs[0]?.progression?.xp ?? 0;
}),
]);
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs);
}
return (
@ -50,7 +67,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
displayName: user?.payloadDisplayName ?? "Guest",
username: user?.username ?? "Not logged in",
}}
xp={userXp}
xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin}
className="border-r"
/>

View file

@ -3,21 +3,31 @@
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Progress } from "@/components/ui/progress";
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 (
<Item>
<ItemHeader>
<ItemTitle className="flex justify-between items-center w-full">
<SparkleIcon />
<div className="flex flex-col items-end">
<span className="uppercase">Rookie</span>
<span className="text-2xl font-bold uppercase">{xp ?? 0}</span>
<span className="uppercase">{levelName}</span>
<span className="text-2xl font-bold uppercase">
{xp.toLocaleString()}
</span>
</div>
</ItemTitle>
</ItemHeader>
<ItemContent>
<Progress value={33} />
<ItemContent className="flex flex-col gap-1">
<Progress value={progress} />
{nextLevelXP !== null && (
<span className="text-[10px] text-muted-foreground text-right">
{Math.floor(progress)}% to next level
</span>
)}
</ItemContent>
</Item>
);

View file

@ -1,10 +1,12 @@
import { seedRanks } from "@/tools/seed/seedRanks";
import { seedUsers } from "@/tools/seed/seedUsers";
import { seedFactions } from "@/tools/seed/seedFactions";
import { seedExperience } from "@/tools/seed/seedExperience";
// Users
await seedUsers();
await seedRanks();
await seedExperience();
// Intelligence
await seedFactions();

View 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();

View 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,
};
}