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 { 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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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