From 98e171d088d0684bb7e2eef134fe10350f24c6e9 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 31 Jul 2026 20:01:23 -0400 Subject: [PATCH] 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 --- src/app/(frontend)/layout.tsx | 47 ++++--- src/components/frontend/blocks/XPDisplay.tsx | 20 ++- src/tools/seed/seedEverything.ts | 2 + src/tools/seed/seedExperience.ts | 132 +++++++++++++++++++ src/utils/xp/resolveLevel.ts | 68 ++++++++++ 5 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 src/tools/seed/seedExperience.ts create mode 100644 src/utils/xp/resolveLevel.ts diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index a523882..1836064 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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" /> diff --git a/src/components/frontend/blocks/XPDisplay.tsx b/src/components/frontend/blocks/XPDisplay.tsx index 0fe4bfe..f10ce4f 100644 --- a/src/components/frontend/blocks/XPDisplay.tsx +++ b/src/components/frontend/blocks/XPDisplay.tsx @@ -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 (
- Rookie - {xp ?? 0} + {levelName} + + {xp.toLocaleString()} +
- - + + + {nextLevelXP !== null && ( + + {Math.floor(progress)}% to next level + + )}
); diff --git a/src/tools/seed/seedEverything.ts b/src/tools/seed/seedEverything.ts index c4cabb1..6dd9dbf 100644 --- a/src/tools/seed/seedEverything.ts +++ b/src/tools/seed/seedEverything.ts @@ -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(); diff --git a/src/tools/seed/seedExperience.ts b/src/tools/seed/seedExperience.ts new file mode 100644 index 0000000..79df5f9 --- /dev/null +++ b/src/tools/seed/seedExperience.ts @@ -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(); diff --git a/src/utils/xp/resolveLevel.ts b/src/utils/xp/resolveLevel.ts new file mode 100644 index 0000000..6e33639 --- /dev/null +++ b/src/utils/xp/resolveLevel.ts @@ -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, + }; +}