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;
|
recruitBestScore?: number;
|
||||||
veteranBestScore?: number;
|
veteranBestScore?: number;
|
||||||
eliteBestScore?: number;
|
eliteBestScore?: number;
|
||||||
|
recruitBestTimeMs?: number;
|
||||||
|
veteranBestTimeMs?: number;
|
||||||
|
eliteBestTimeMs?: number;
|
||||||
xpEarned?: number;
|
xpEarned?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -136,6 +139,19 @@ export async function submitOpordRecallResult(
|
||||||
const eliteBestScore =
|
const eliteBestScore =
|
||||||
input.difficulty === "elite" ? tierBestScore : (current.eliteBestScore ?? 0);
|
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;
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
|
|
@ -153,6 +169,9 @@ export async function submitOpordRecallResult(
|
||||||
recruitBestScore,
|
recruitBestScore,
|
||||||
veteranBestScore,
|
veteranBestScore,
|
||||||
eliteBestScore,
|
eliteBestScore,
|
||||||
|
recruitBestTimeMs,
|
||||||
|
veteranBestTimeMs,
|
||||||
|
eliteBestTimeMs,
|
||||||
xpEarned: opordRecallXp,
|
xpEarned: opordRecallXp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,21 @@ export const Profiles: CollectionConfig = {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0,
|
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",
|
name: "xpEarned",
|
||||||
label: "XP Earned",
|
label: "XP Earned",
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,19 @@ type LeaderboardRow = {
|
||||||
readonly gamesPlayed: number;
|
readonly gamesPlayed: number;
|
||||||
readonly bestScore: number;
|
readonly bestScore: number;
|
||||||
readonly bestAccuracy: number;
|
readonly bestAccuracy: number;
|
||||||
|
readonly bestTimeMs: number | null;
|
||||||
readonly isCurrentUser: boolean;
|
readonly isCurrentUser: boolean;
|
||||||
readonly isOutsideTopTen?: 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 {
|
function rankClass(index: number): string {
|
||||||
if (index === 0) return "font-bold text-amber-400";
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
if (index === 1) return "font-semibold text-slate-300";
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
|
@ -80,6 +89,7 @@ export function OpordLeaderboard({
|
||||||
gamesPlayed: opordRecall?.gamesPlayed ?? 0,
|
gamesPlayed: opordRecall?.gamesPlayed ?? 0,
|
||||||
bestScore: opordRecall?.[tier.scoreField] ?? 0,
|
bestScore: opordRecall?.[tier.scoreField] ?? 0,
|
||||||
bestAccuracy: opordRecall?.bestAccuracy ?? 0,
|
bestAccuracy: opordRecall?.bestAccuracy ?? 0,
|
||||||
|
bestTimeMs: opordRecall?.[tier.timeField] ?? null,
|
||||||
isCurrentUser: userId !== null && String(userId) === String(currentUserId),
|
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 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">Games</th>
|
||||||
<th className="px-2 py-2 text-right font-medium">Accuracy</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
|
<th
|
||||||
aria-sort="descending"
|
aria-sort="descending"
|
||||||
className="px-4 py-2 text-right font-medium text-foreground"
|
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">
|
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
|
||||||
{Math.round(row.bestAccuracy * 100)}%
|
{Math.round(row.bestAccuracy * 100)}%
|
||||||
</td>
|
</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">
|
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
|
||||||
{row.bestScore}
|
{row.bestScore}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
|
||||||
export type OpordLeaderboardFields = {
|
export type OpordLeaderboardFields = {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly scoreField: keyof OpordApiStats;
|
readonly scoreField: keyof OpordApiStats;
|
||||||
|
readonly timeField: keyof OpordApiStats;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OpordApiStats = {
|
export type OpordApiStats = {
|
||||||
|
|
@ -12,6 +13,9 @@ export type OpordApiStats = {
|
||||||
readonly recruitBestScore: number | null;
|
readonly recruitBestScore: number | null;
|
||||||
readonly veteranBestScore: number | null;
|
readonly veteranBestScore: number | null;
|
||||||
readonly eliteBestScore: number | null;
|
readonly eliteBestScore: number | null;
|
||||||
|
readonly recruitBestTimeMs: number | null;
|
||||||
|
readonly veteranBestTimeMs: number | null;
|
||||||
|
readonly eliteBestTimeMs: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApiUser = {
|
type ApiUser = {
|
||||||
|
|
@ -29,9 +33,21 @@ type ApiProfile = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OPORD_LEADERBOARD_FIELDS = {
|
export const OPORD_LEADERBOARD_FIELDS = {
|
||||||
recruit: { label: "Recruit", scoreField: "recruitBestScore" },
|
recruit: {
|
||||||
veteran: { label: "Veteran", scoreField: "veteranBestScore" },
|
label: "Recruit",
|
||||||
elite: { label: "Elite", scoreField: "eliteBestScore" },
|
scoreField: "recruitBestScore",
|
||||||
|
timeField: "recruitBestTimeMs",
|
||||||
|
},
|
||||||
|
veteran: {
|
||||||
|
label: "Veteran",
|
||||||
|
scoreField: "veteranBestScore",
|
||||||
|
timeField: "veteranBestTimeMs",
|
||||||
|
},
|
||||||
|
elite: {
|
||||||
|
label: "Elite",
|
||||||
|
scoreField: "eliteBestScore",
|
||||||
|
timeField: "eliteBestTimeMs",
|
||||||
|
},
|
||||||
} as const satisfies Record<OpordDifficulty, OpordLeaderboardFields>;
|
} as const satisfies Record<OpordDifficulty, OpordLeaderboardFields>;
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||||
|
|
@ -69,6 +85,9 @@ function readOpordRecall(value: unknown): OpordApiStats | null {
|
||||||
recruitBestScore: readNumber(value.recruitBestScore),
|
recruitBestScore: readNumber(value.recruitBestScore),
|
||||||
veteranBestScore: readNumber(value.veteranBestScore),
|
veteranBestScore: readNumber(value.veteranBestScore),
|
||||||
eliteBestScore: readNumber(value.eliteBestScore),
|
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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -298,6 +299,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260908_194435_add_expose_edge_mines_field.up,
|
up: migration_20260908_194435_add_expose_edge_mines_field.up,
|
||||||
down: migration_20260908_194435_add_expose_edge_mines_field.down,
|
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;
|
recruitBestScore?: number | null;
|
||||||
veteranBestScore?: number | null;
|
veteranBestScore?: number | null;
|
||||||
eliteBestScore?: number | null;
|
eliteBestScore?: number | null;
|
||||||
|
recruitBestTimeMs?: number | null;
|
||||||
|
veteranBestTimeMs?: number | null;
|
||||||
|
eliteBestTimeMs?: number | null;
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -4584,6 +4587,9 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
recruitBestScore?: T;
|
recruitBestScore?: T;
|
||||||
veteranBestScore?: T;
|
veteranBestScore?: T;
|
||||||
eliteBestScore?: T;
|
eliteBestScore?: T;
|
||||||
|
recruitBestTimeMs?: T;
|
||||||
|
veteranBestTimeMs?: T;
|
||||||
|
eliteBestTimeMs?: T;
|
||||||
xpEarned?: 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