From 17f3cc6f47f0bc55487e35cef154954a23b8c23d Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 14 Sep 2026 15:38:49 -0400 Subject: [PATCH] feat(profile): promote action and blackjack stats in profile XP Server action pairing the GM promote button with the promotion service, plus the blackjack minigame stats block counted into profile XP totals. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../(frontend)/profile/[username]/actions.ts | 20 +++++++ .../(frontend)/profile/[username]/page.tsx | 14 +++++ src/collections/users/Profiles.ts | 60 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/src/app/(frontend)/profile/[username]/actions.ts b/src/app/(frontend)/profile/[username]/actions.ts index 92b3522..786c923 100644 --- a/src/app/(frontend)/profile/[username]/actions.ts +++ b/src/app/(frontend)/profile/[username]/actions.ts @@ -20,6 +20,7 @@ import { INTEL_EXCERPT_MAX_LENGTH, PERSONAL_EXCERPT_MAX_LENGTH, } from "@/lib/profile/excerpt-limits"; +import { promoteMember as promoteMemberService } from "@/lib/promotions"; interface ActionResult { success: boolean; @@ -270,3 +271,22 @@ export async function updateIntelExcerpt(input: { return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; } } + +/** + * Promote a member to a new rank. Authority is resolved server-side from the + * actor's rank promotionCeiling (or admin/developer bypass); the client only + * supplies the target username and rank id. + */ +export async function promoteMember( + username: string, + rankId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const result = await promoteMemberService(payload, user, username, rankId); + return { success: true, data: { profileId: result.profileId } }; + } catch (e) { + if (e instanceof Error && e.message === "Unauthorized") throw e; + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index 4ab885b..dd38459 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -41,7 +41,9 @@ import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGr import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors"; +import { PromoteButton } from "@/components/frontend/profile/PromoteButton"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; +import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions"; import type { EvaluationMissionOption, EvaluationSummary, @@ -361,6 +363,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) { : null; const canSubmitEvaluation = viewer !== null && !isProfileOwner; const rank = profile.rank as Rank; + const promotionResult = viewer ? await resolvePromotionAuthority(payload, viewer) : null; + const allowedTargetRanks = promotionResult + ? allowedPromotionTargets(promotionResult, rank) + : []; const awardEntries = (profile.progression?.awards ?? []).filter( (entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null, ); @@ -607,6 +613,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) { /> )} + {allowedTargetRanks.length > 0 && ( + ({ id: r.id, name: r.name }))} + /> + )} + {/* ── Combat Record ──────────────────────────────────── */}
diff --git a/src/collections/users/Profiles.ts b/src/collections/users/Profiles.ts index 98543e9..b41c428 100644 --- a/src/collections/users/Profiles.ts +++ b/src/collections/users/Profiles.ts @@ -133,6 +133,7 @@ export const Profiles: CollectionConfig = { aimTrainer?: { xpEarned?: number | null }; opordRecall?: { xpEarned?: number | null }; poker?: { xpEarned?: number | null }; + blackjack?: { xpEarned?: number | null }; }; voiceover?: { xpEarned?: number | null }; }; @@ -150,6 +151,7 @@ export const Profiles: CollectionConfig = { const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0; const opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0; const pokerXp = progressionData?.minigames?.poker?.xpEarned ?? 0; + const blackjackXp = progressionData?.minigames?.blackjack?.xpEarned ?? 0; const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0; if (awardIds.length > 0) { @@ -195,6 +197,8 @@ export const Profiles: CollectionConfig = { xpAcc += opordRecallXp; // Poker sit-and-go: XP from placement payouts xpAcc += pokerXp; + // Blackjack sessions: XP from profit-based payouts + xpAcc += blackjackXp; // Voiceover submissions: XP awarded per accepted submission xpAcc += voiceoverXp; @@ -705,8 +709,64 @@ export const Profiles: CollectionConfig = { }, ], }, + { + name: "blackjack", + label: "Blackjack", + type: "group", + fields: [ + { + name: "gamesPlayed", + label: "Sessions Played", + type: "number", + defaultValue: 0, + }, + { + name: "roundsWon", + label: "Rounds Won", + type: "number", + defaultValue: 0, + }, + { + name: "blackjacksHit", + label: "Blackjacks Hit", + type: "number", + defaultValue: 0, + }, + { + name: "bestScore", + label: "Best Bankroll", + type: "number", + defaultValue: 0, + }, + { + name: "xpEarned", + label: "XP Earned", + type: "number", + defaultValue: 0, + }, + ], + }, ], }, + { + name: "chipBank", + label: "Chip Bank", + type: "number", + defaultValue: 0, + min: 0, + admin: { + description: + "Persistent play-chip pool for the card minigames (poker, blackjack). Earned from the other minigames; never creates chips itself.", + }, + }, + { + name: "chipBankRefilledAt", + label: "Chip Bank Top-Up At", + type: "date", + admin: { + description: "Last daily chip top-up claim; one claim per 24 hours.", + }, + }, { name: "voiceover", label: "Voiceover",