1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:38:49 -04:00
parent 77034a8e67
commit 17f3cc6f47
3 changed files with 94 additions and 0 deletions

View file

@ -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<T = undefined> {
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<ActionResult<{ profileId: number }>> {
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" };
}
}

View file

@ -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 && (
<PromoteButton
username={username}
currentRankName={rank.name}
targetRanks={allowedTargetRanks.map((r) => ({ id: r.id, name: r.name }))}
/>
)}
{/* ── Combat Record ──────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">

View file

@ -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",