1
0
Fork 0

feat(opord): track per-tier best times with leaderboard column and par backfill

This commit is contained in:
Jason Fraley 2026-09-09 11:37:51 -04:00
parent eb77f45b95
commit cbe88c141b
9 changed files with 24691 additions and 4 deletions

View file

@ -107,6 +107,9 @@ export async function submitOpordRecallResult(
recruitBestScore?: number;
veteranBestScore?: number;
eliteBestScore?: number;
recruitBestTimeMs?: number;
veteranBestTimeMs?: number;
eliteBestTimeMs?: number;
xpEarned?: number;
};
};
@ -136,6 +139,19 @@ export async function submitOpordRecallResult(
const eliteBestScore =
input.difficulty === "elite" ? tierBestScore : (current.eliteBestScore ?? 0);
const tierBestTimeKey = `${input.difficulty}BestTimeMs` as
| "recruitBestTimeMs"
| "veteranBestTimeMs"
| "eliteBestTimeMs";
const prevTierBestTime = current[tierBestTimeKey];
const tierBestTimeMs = prevTierBestTime == null ? timeMs : Math.min(prevTierBestTime, timeMs);
const recruitBestTimeMs =
input.difficulty === "recruit" ? tierBestTimeMs : (current.recruitBestTimeMs ?? null);
const veteranBestTimeMs =
input.difficulty === "veteran" ? tierBestTimeMs : (current.veteranBestTimeMs ?? null);
const eliteBestTimeMs =
input.difficulty === "elite" ? tierBestTimeMs : (current.eliteBestTimeMs ?? null);
const { xp: _xp, ...progressionRest } = progression;
await payload.update({
@ -153,6 +169,9 @@ export async function submitOpordRecallResult(
recruitBestScore,
veteranBestScore,
eliteBestScore,
recruitBestTimeMs,
veteranBestTimeMs,
eliteBestTimeMs,
xpEarned: opordRecallXp,
},
},

View file

@ -608,6 +608,21 @@ export const Profiles: CollectionConfig = {
type: "number",
defaultValue: 0,
},
{
name: "recruitBestTimeMs",
label: "Recruit Best Time (ms)",
type: "number",
},
{
name: "veteranBestTimeMs",
label: "Veteran Best Time (ms)",
type: "number",
},
{
name: "eliteBestTimeMs",
label: "Elite Best Time (ms)",
type: "number",
},
{
name: "xpEarned",
label: "XP Earned",

View file

@ -17,10 +17,19 @@ type LeaderboardRow = {
readonly gamesPlayed: number;
readonly bestScore: number;
readonly bestAccuracy: number;
readonly bestTimeMs: number | null;
readonly isCurrentUser: boolean;
readonly isOutsideTopTen?: boolean;
};
function formatTime(ms: number | null): string {
if (ms === null || ms <= 0) return "-";
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function rankClass(index: number): string {
if (index === 0) return "font-bold text-amber-400";
if (index === 1) return "font-semibold text-slate-300";
@ -80,6 +89,7 @@ export function OpordLeaderboard({
gamesPlayed: opordRecall?.gamesPlayed ?? 0,
bestScore: opordRecall?.[tier.scoreField] ?? 0,
bestAccuracy: opordRecall?.bestAccuracy ?? 0,
bestTimeMs: opordRecall?.[tier.timeField] ?? null,
isCurrentUser: userId !== null && String(userId) === String(currentUserId),
};
});
@ -151,6 +161,7 @@ export function OpordLeaderboard({
<th className="px-2 py-2 font-medium">Operator</th>
<th className="px-2 py-2 text-right font-medium">Games</th>
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
<th className="px-2 py-2 text-right font-medium">Time</th>
<th
aria-sort="descending"
className="px-4 py-2 text-right font-medium text-foreground"
@ -204,6 +215,9 @@ export function OpordLeaderboard({
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
{Math.round(row.bestAccuracy * 100)}%
</td>
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
{formatTime(row.bestTimeMs)}
</td>
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
{row.bestScore}
</td>

View file

@ -3,6 +3,7 @@ import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
export type OpordLeaderboardFields = {
readonly label: string;
readonly scoreField: keyof OpordApiStats;
readonly timeField: keyof OpordApiStats;
};
export type OpordApiStats = {
@ -12,6 +13,9 @@ export type OpordApiStats = {
readonly recruitBestScore: number | null;
readonly veteranBestScore: number | null;
readonly eliteBestScore: number | null;
readonly recruitBestTimeMs: number | null;
readonly veteranBestTimeMs: number | null;
readonly eliteBestTimeMs: number | null;
};
type ApiUser = {
@ -29,9 +33,21 @@ type ApiProfile = {
};
export const OPORD_LEADERBOARD_FIELDS = {
recruit: { label: "Recruit", scoreField: "recruitBestScore" },
veteran: { label: "Veteran", scoreField: "veteranBestScore" },
elite: { label: "Elite", scoreField: "eliteBestScore" },
recruit: {
label: "Recruit",
scoreField: "recruitBestScore",
timeField: "recruitBestTimeMs",
},
veteran: {
label: "Veteran",
scoreField: "veteranBestScore",
timeField: "veteranBestTimeMs",
},
elite: {
label: "Elite",
scoreField: "eliteBestScore",
timeField: "eliteBestTimeMs",
},
} as const satisfies Record<OpordDifficulty, OpordLeaderboardFields>;
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
@ -69,6 +85,9 @@ function readOpordRecall(value: unknown): OpordApiStats | null {
recruitBestScore: readNumber(value.recruitBestScore),
veteranBestScore: readNumber(value.veteranBestScore),
eliteBestScore: readNumber(value.eliteBestScore),
recruitBestTimeMs: readNumber(value.recruitBestTimeMs),
veteranBestTimeMs: readNumber(value.veteranBestTimeMs),
eliteBestTimeMs: readNumber(value.eliteBestTimeMs),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_recruit_best_time_ms" numeric;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_veteran_best_time_ms" numeric;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_elite_best_time_ms" numeric;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'custom_minefields')
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'custom_minefields' AND column_name = 'creator_xp_bank'
) THEN
ALTER TABLE "custom_minefields" ADD COLUMN "creator_xp_bank" numeric DEFAULT 0;
END IF;
END $$;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_recruit_best_time_ms";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_veteran_best_time_ms";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_elite_best_time_ms";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'custom_minefields')
AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'custom_minefields' AND column_name = 'creator_xp_bank'
) THEN
ALTER TABLE "custom_minefields" DROP COLUMN "creator_xp_bank";
END IF;
END $$;`)
}

View file

@ -48,6 +48,7 @@ import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
export const migrations = [
{
@ -298,6 +299,11 @@ export const migrations = [
{
up: migration_20260908_194435_add_expose_edge_mines_field.up,
down: migration_20260908_194435_add_expose_edge_mines_field.down,
name: '20260908_194435_add_expose_edge_mines_field'
name: '20260908_194435_add_expose_edge_mines_field',
},
{
up: migration_20260909_153427_add_opord_recall_best_times.up,
down: migration_20260909_153427_add_opord_recall_best_times.down,
name: '20260909_153427_add_opord_recall_best_times'
},
];

View file

@ -2062,6 +2062,9 @@ export interface Profile {
recruitBestScore?: number | null;
veteranBestScore?: number | null;
eliteBestScore?: number | null;
recruitBestTimeMs?: number | null;
veteranBestTimeMs?: number | null;
eliteBestTimeMs?: number | null;
xpEarned?: number | null;
};
};
@ -4584,6 +4587,9 @@ export interface ProfilesSelect<T extends boolean = true> {
recruitBestScore?: T;
veteranBestScore?: T;
eliteBestScore?: T;
recruitBestTimeMs?: T;
veteranBestTimeMs?: T;
eliteBestTimeMs?: T;
xpEarned?: T;
};
};

View file

@ -0,0 +1,86 @@
import "dotenv/config";
import { getPayload } from "payload";
import config from "@payload-config";
import { OPORD_DIFFICULTIES } from "../../lib/minigames/opord-recall";
// Usage: `bun run src/tools/seed/backfillOpordBestTimes.ts`.
// Backfills OPORD Recall best times introduced after the speed-bonus scoring
// change: any profile with a nonzero per-tier best score but no recorded time
// gets the tier's par time as the neutral placeholder, so pre-change players
// are accounted for on the leaderboard's Time column. Idempotent.
type Tier = "recruit" | "veteran" | "elite";
const TIERS: readonly Tier[] = ["recruit", "veteran", "elite"];
export const backfillOpordBestTimes = async () => {
const payload = await getPayload({ config });
payload.logger.info("Backfilling OPORD Recall best times...");
let updated = 0;
let page = 1;
const limit = 100;
for (;;) {
const res = await payload.find({
collection: "profiles",
page,
limit,
depth: 0,
overrideAccess: true,
});
for (const profile of res.docs) {
const progression = (profile.progression ?? {}) as {
minigames?: {
opordRecall?: Record<
| "recruitBestScore"
| "veteranBestScore"
| "eliteBestScore"
| "recruitBestTimeMs"
| "veteranBestTimeMs"
| "eliteBestTimeMs",
number | null | undefined
>;
};
};
const stats = progression.minigames?.opordRecall;
if (!stats) continue;
const patch: Partial<Record<"recruitBestTimeMs" | "veteranBestTimeMs" | "eliteBestTimeMs", number>> = {};
for (const tier of TIERS) {
const score = stats[`${tier}BestScore`] ?? 0;
const time = stats[`${tier}BestTimeMs`];
if (score > 0 && time == null) {
patch[`${tier}BestTimeMs`] = OPORD_DIFFICULTIES[tier].parTimeMs;
}
}
if (Object.keys(patch).length === 0) continue;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progression,
minigames: {
...(progression.minigames ?? {}),
opordRecall: {
...stats,
...patch,
},
},
},
},
overrideAccess: true,
depth: 0,
});
updated += 1;
}
if (page >= res.totalPages) break;
page += 1;
}
payload.logger.info("--- backfill summary ---");
payload.logger.info(`Profiles updated: ${updated}`);
};
await backfillOpordBestTimes();
process.exit(0);