feat(opord): track per-tier best times with leaderboard column and par backfill
This commit is contained in:
parent
eb77f45b95
commit
cbe88c141b
9 changed files with 24691 additions and 4 deletions
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
24487
src/migrations/20260909_153427_add_opord_recall_best_times.json
Normal file
24487
src/migrations/20260909_153427_add_opord_recall_best_times.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 $$;`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
86
src/tools/seed/backfillOpordBestTimes.ts
Normal file
86
src/tools/seed/backfillOpordBestTimes.ts
Normal 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);
|
||||
Loading…
Reference in a new issue