Compare commits
No commits in common. "42cbcbac3c3840cb58ec54212b8eae876bf0b360" and "cbe88c141b65a2c5d20aff089f357b7aff74104b" have entirely different histories.
42cbcbac3c
...
cbe88c141b
16 changed files with 118 additions and 24815 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.26",
|
||||
"version": "0.2.25",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -205,15 +205,6 @@ export async function uploadSideOpModlist(
|
|||
if (file.size > 10 * 1024 * 1024) {
|
||||
return { success: false, error: "Mod list file must be 10 MB or smaller." };
|
||||
}
|
||||
// Only HTML mod lists exported from the Arma 3 launcher are supported. Some
|
||||
// browsers report an empty file.type, so also accept by extension.
|
||||
const isHtml = file.type === "text/html" || /\.html?$/i.test(file.name);
|
||||
if (!isHtml) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Mod list must be an HTML file exported from the Arma 3 launcher.",
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const created = await payload.create({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { transcodeToMp3 } from "@/lib/voiceover/transcode";
|
||||
|
|
@ -243,6 +245,34 @@ export async function reviewVoiceSubmission(
|
|||
return { success: false, error: "The script for this submission no longer exists." };
|
||||
}
|
||||
|
||||
const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id;
|
||||
|
||||
// Resolve the profile before mutating anything so a missing profile fails
|
||||
// the review cleanly instead of leaving the submission accepted without XP.
|
||||
let profileId: number | undefined;
|
||||
let progression: {
|
||||
xp?: unknown;
|
||||
voiceover?: { xpEarned?: number };
|
||||
} = {};
|
||||
if (decision === "accepted") {
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: submitterId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const profile = profileRes.docs[0];
|
||||
if (!profile) {
|
||||
return { success: false, error: "No profile found for the submitter." };
|
||||
}
|
||||
profileId = profile.id;
|
||||
progression = (profile.progression ?? {}) as {
|
||||
xp?: unknown;
|
||||
voiceover?: { xpEarned?: number };
|
||||
};
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "voice-submissions",
|
||||
id: submissionId,
|
||||
|
|
@ -254,6 +284,65 @@ export async function reviewVoiceSubmission(
|
|||
depth: 0,
|
||||
});
|
||||
|
||||
if (decision === "accepted" && profileId !== undefined) {
|
||||
const voiceover = progression.voiceover ?? {};
|
||||
const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount;
|
||||
// Strip the virtual `xp` field before writing (computed, not stored).
|
||||
const { xp: _xp, ...progressionRest } = progression;
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profileId,
|
||||
data: {
|
||||
progression: {
|
||||
...progressionRest,
|
||||
voiceover: {
|
||||
...voiceover,
|
||||
xpEarned,
|
||||
},
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
broadcastToUser("profile-update", submitterId, {
|
||||
reason: "xp",
|
||||
xpGained: script.xpAmount,
|
||||
profileId,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:accepted",
|
||||
title: "Voiceover accepted",
|
||||
message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
} else {
|
||||
await notifyUser(payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:rejected",
|
||||
title: "Voiceover rejected",
|
||||
message: `Your recording for "${script.title}" was not accepted this time.`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected,
|
||||
message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`,
|
||||
actor: user.id,
|
||||
targetCollection: "voice-submissions",
|
||||
targetId: submissionId,
|
||||
data: {
|
||||
scriptId: script.id,
|
||||
scriptTitle: script.title,
|
||||
submitterId,
|
||||
decision,
|
||||
reviewNote: reviewNote ?? null,
|
||||
xpAwarded: decision === "accepted" ? script.xpAmount : 0,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: { id: submissionId, status: decision } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/component
|
|||
import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField'
|
||||
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
||||
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
||||
import { AudioPreviewField as AudioPreviewField_6bccef9bf4ea41ed573ab9f36fa579b2 } from '@/components/admin/voiceover/AudioPreviewField'
|
||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
|
|
@ -59,7 +58,6 @@ export const importMap = {
|
|||
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
|
||||
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
||||
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
||||
"@/components/admin/voiceover/AudioPreviewField#AudioPreviewField": AudioPreviewField_6bccef9bf4ea41ed573ab9f36fa579b2,
|
||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
|
|
|
|||
|
|
@ -63,15 +63,13 @@ export const VoiceScripts: CollectionConfig = {
|
|||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "draft",
|
||||
defaultValue: "open",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.",
|
||||
description: "Closed scripts reject new submissions",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,186 +1,7 @@
|
|||
import {
|
||||
CollectionConfig,
|
||||
type CollectionAfterChangeHook,
|
||||
type CollectionBeforeChangeHook,
|
||||
} from "payload";
|
||||
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||
import { CollectionConfig } from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
async function fetchScript(payload: PayloadType, id: number): Promise<VoiceScript | null> {
|
||||
try {
|
||||
return await payload.findByID({
|
||||
collection: "voice-scripts",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptIdOf = (script: VoiceSubmission["script"]): number =>
|
||||
typeof script === "object" && script !== null ? script.id : script;
|
||||
|
||||
const submitterIdOf = (user: VoiceSubmission["user"]): number =>
|
||||
typeof user === "object" && user !== null ? user.id : user;
|
||||
|
||||
const reviewerName = (reqUser: unknown): string => {
|
||||
const user = reqUser as Pick<User, "displayName" | "username"> | null | undefined;
|
||||
return user?.displayName ?? user?.username ?? "A reviewer";
|
||||
};
|
||||
|
||||
/**
|
||||
* XP and notifications for review decisions live in hooks so both the
|
||||
* frontend action and direct admin-panel edits take the same path. XP is
|
||||
* awarded only on a pending-to-accepted transition, so ordinary re-saves
|
||||
* never double-award.
|
||||
*/
|
||||
const guardReviewTransition: CollectionBeforeChangeHook = async ({
|
||||
operation,
|
||||
data,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (operation !== "update") return data;
|
||||
const nextStatus = data?.status;
|
||||
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||
if (nextStatus !== "accepted" && nextStatus !== "rejected") return data;
|
||||
|
||||
const script = await fetchScript(req.payload, scriptIdOf(originalDoc.script));
|
||||
if (!script) {
|
||||
throw new Error("The script for this submission no longer exists.");
|
||||
}
|
||||
|
||||
if (nextStatus === "accepted") {
|
||||
const profileRes = await req.payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: submitterIdOf(originalDoc.user) } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!profileRes.docs[0]) {
|
||||
throw new Error("No profile found for the submitter.");
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const applyReviewSideEffects: CollectionAfterChangeHook = async ({
|
||||
operation,
|
||||
doc,
|
||||
previousDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (operation !== "update") return doc;
|
||||
if (previousDoc?.status !== "pending") return doc;
|
||||
if (doc.status !== "accepted" && doc.status !== "rejected") return doc;
|
||||
|
||||
const submitterId = submitterIdOf(doc.user);
|
||||
const script = await fetchScript(req.payload, scriptIdOf(doc.script));
|
||||
|
||||
if (doc.status === "accepted") {
|
||||
const profileRes = await req.payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: submitterId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const profile = profileRes.docs[0];
|
||||
if (!profile) {
|
||||
req.payload.logger.error(
|
||||
`Voiceover submission ${doc.id} accepted but submitter ${submitterId} has no profile; XP not awarded.`,
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const progression = (profile.progression ?? {}) as {
|
||||
xp?: unknown;
|
||||
voiceover?: { xpEarned?: number };
|
||||
};
|
||||
const voiceover = progression.voiceover ?? {};
|
||||
const xpGained = script?.xpAmount ?? 0;
|
||||
const { xp: _xp, ...progressionRest } = progression;
|
||||
|
||||
await req.payload.update({
|
||||
collection: "profiles",
|
||||
id: profile.id,
|
||||
data: {
|
||||
progression: {
|
||||
...progressionRest,
|
||||
voiceover: {
|
||||
...voiceover,
|
||||
xpEarned: (voiceover.xpEarned ?? 0) + xpGained,
|
||||
},
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
broadcastToUser("profile-update", submitterId, {
|
||||
reason: "xp",
|
||||
xpGained,
|
||||
profileId: profile.id,
|
||||
});
|
||||
await notifyUser(req.payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:accepted",
|
||||
title: "Voiceover accepted",
|
||||
message: `Your recording for "${script?.title ?? "a script"}" was accepted (+${xpGained} XP).`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
await emitGameEvent(req.payload, {
|
||||
type: EventTypes.VoiceoverAccepted,
|
||||
message: `${reviewerName(req.user)} accepted a voiceover submission for "${script?.title ?? "a script"}"`,
|
||||
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||
targetCollection: "voice-submissions",
|
||||
targetId: doc.id,
|
||||
data: {
|
||||
scriptId: scriptIdOf(doc.script),
|
||||
scriptTitle: script?.title ?? null,
|
||||
submitterId,
|
||||
decision: "accepted",
|
||||
reviewNote: doc.reviewNote ?? null,
|
||||
xpAwarded: xpGained,
|
||||
},
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
await notifyUser(req.payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:rejected",
|
||||
title: "Voiceover rejected",
|
||||
message: `Your recording for "${script?.title ?? "a script"}" was not accepted this time.`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
await emitGameEvent(req.payload, {
|
||||
type: EventTypes.VoiceoverRejected,
|
||||
message: `${reviewerName(req.user)} rejected a voiceover submission for "${script?.title ?? "a script"}"`,
|
||||
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||
targetCollection: "voice-submissions",
|
||||
targetId: doc.id,
|
||||
data: {
|
||||
scriptId: scriptIdOf(doc.script),
|
||||
scriptTitle: script?.title ?? null,
|
||||
submitterId,
|
||||
decision: "rejected",
|
||||
reviewNote: doc.reviewNote ?? null,
|
||||
xpAwarded: 0,
|
||||
},
|
||||
});
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const VoiceSubmissions: CollectionConfig = {
|
||||
slug: "voice-submissions",
|
||||
|
|
@ -214,16 +35,6 @@ export const VoiceSubmissions: CollectionConfig = {
|
|||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "audioPreview",
|
||||
type: "ui",
|
||||
label: "Take audio",
|
||||
admin: {
|
||||
components: {
|
||||
Field: "@/components/admin/voiceover/AudioPreviewField#AudioPreviewField",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "script",
|
||||
type: "relationship",
|
||||
|
|
@ -275,8 +86,4 @@ export const VoiceSubmissions: CollectionConfig = {
|
|||
// scripts, …) stay blocked by Payload's built-in checks.
|
||||
upload: true,
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [guardReviewTransition],
|
||||
afterChange: [applyReviewSideEffects],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useDocumentInfo } from "@payloadcms/ui";
|
||||
import React from "react";
|
||||
|
||||
type UploadDocData = {
|
||||
filename?: string | null;
|
||||
filesize?: number | null;
|
||||
mimeType?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number | null | undefined): string => {
|
||||
if (!bytes || bytes <= 0) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline audio player for upload collections. Registered on voice-submissions as
|
||||
* a `ui` field so reviewers can listen to a take directly in the admin panel.
|
||||
* Reads the document's file URL from document info (no form state involved).
|
||||
*/
|
||||
export const AudioPreviewField = () => {
|
||||
const { data } = useDocumentInfo();
|
||||
const doc = (data ?? {}) as UploadDocData;
|
||||
const url = doc.url ?? (doc.filename ? `/api/media/file/${doc.filename}` : "");
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--theme-elevation-500)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
No audio file on this submission yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = [doc.filename, doc.mimeType, formatBytes(doc.filesize)].filter(Boolean).join(" | ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<audio controls preload="metadata" src={url} style={{ width: "100%" }}>
|
||||
Your browser does not support audio playback.
|
||||
</audio>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--theme-elevation-500)",
|
||||
fontSize: "12px",
|
||||
marginTop: "4px",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPreviewField;
|
||||
|
|
@ -34,6 +34,7 @@ export function EodArena({
|
|||
const clearedSectors = [
|
||||
initialStats.bestTrainingTime,
|
||||
initialStats.bestPerimeterTime,
|
||||
initialStats.bestSupplyRoadTime,
|
||||
].filter((time) => time > 0).length;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,8 +55,8 @@ export function EodArena({
|
|||
{!customUnlocked && (
|
||||
<UnlockNotice
|
||||
title="custom minefields"
|
||||
requirement="you have cleared Training Pad and FOB Perimeter"
|
||||
progress={`${clearedSectors} of 2 sectors cleared`}
|
||||
requirement="you have cleared Training Pad, FOB Perimeter, and Main Supply Road"
|
||||
progress={`${clearedSectors} of 3 sectors cleared`}
|
||||
/>
|
||||
)}
|
||||
<MinesweeperBoard
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
|||
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isEodUnlocked } from "@/lib/minigames/custom/eod";
|
||||
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
||||
|
||||
type Phase = "ready" | "playing" | "over";
|
||||
|
|
@ -228,11 +227,8 @@ export function MinesweeperBoard({
|
|||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
|
||||
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
|
||||
const customUnlocked = isEodUnlocked({
|
||||
bestTrainingTime: stats.bestTrainingTime,
|
||||
bestPerimeterTime: stats.bestPerimeterTime,
|
||||
bestSupplyRoadTime: stats.bestSupplyRoadTime,
|
||||
});
|
||||
const customUnlocked =
|
||||
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0;
|
||||
const isLargeCustomGame =
|
||||
difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD;
|
||||
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
||||
|
|
@ -816,7 +812,7 @@ export function MinesweeperBoard({
|
|||
</div>
|
||||
) : !customUnlocked ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Clear the Training Pad and FOB Perimeter to unlock custom minefields.
|
||||
Clear all three standard locations to unlock custom minefields.
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ export interface EodUnlockStats {
|
|||
bestSupplyRoadTime: number;
|
||||
}
|
||||
|
||||
/** A player unlocks the custom minefield editor once they cleared Training Pad and FOB Perimeter. */
|
||||
/** A player unlocks the custom minefield editor once they cleared all three standard difficulties. */
|
||||
export function isEodUnlocked(stats: EodUnlockStats): boolean {
|
||||
return stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0;
|
||||
return (
|
||||
stats.bestTrainingTime > 0 &&
|
||||
stats.bestPerimeterTime > 0 &&
|
||||
stats.bestSupplyRoadTime > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function validateMinefieldName(name: unknown): { error?: string; name: string } {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ export async function listScriptsForUser(
|
|||
const [scriptsRes, submissionsRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "voice-scripts",
|
||||
where: { status: { not_equals: "draft" } },
|
||||
sort: "-createdAt",
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
|
|
@ -122,7 +121,7 @@ export async function getScriptForUser(
|
|||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!script || script.status === "draft") return null;
|
||||
if (!script) return null;
|
||||
|
||||
const submissionsRes = await payload.find({
|
||||
collection: "voice-submissions",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_voice_scripts_status" ADD VALUE IF NOT EXISTS 'draft' BEFORE 'open';`);
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DATA TYPE text;
|
||||
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DEFAULT 'open'::text;
|
||||
DROP TYPE "public"."enum_voice_scripts_status";
|
||||
CREATE TYPE "public"."enum_voice_scripts_status" AS ENUM('open', 'closed');
|
||||
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DEFAULT 'open'::"public"."enum_voice_scripts_status";
|
||||
ALTER TABLE "voice_scripts" ALTER COLUMN "status" SET DATA TYPE "public"."enum_voice_scripts_status" USING "status"::"public"."enum_voice_scripts_status";`);
|
||||
}
|
||||
|
|
@ -49,7 +49,6 @@ import * as migration_20260908_172241_add_custom_minigame_collections from './20
|
|||
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';
|
||||
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -305,11 +304,6 @@ export const migrations = [
|
|||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
||||
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
||||
name: '20260909_223955_add_voice_script_draft_status'
|
||||
name: '20260909_153427_add_opord_recall_best_times'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3157,9 +3157,9 @@ export interface VoiceScript {
|
|||
*/
|
||||
spoiler?: boolean | null;
|
||||
/**
|
||||
* Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.
|
||||
* Closed scripts reject new submissions
|
||||
*/
|
||||
status: 'draft' | 'open' | 'closed';
|
||||
status: 'open' | 'closed';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -465,31 +465,25 @@ describe("Custom minefields", () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Unlock gating", () => {
|
||||
it("locks custom minefields until Training Pad and FOB Perimeter are cleared", () => {
|
||||
it("locks custom minefields until a win is recorded on all three difficulties", () => {
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("unlocks once Training Pad and FOB Perimeter have best times above zero", () => {
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 0 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a Main Supply Road win substitute for the required sectors", () => {
|
||||
it("unlocks once every difficulty has a best time above zero", () => {
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 120 }),
|
||||
).toBe(false);
|
||||
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 1 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 120 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue