1
0
Fork 0

Compare commits

..

6 commits

16 changed files with 24812 additions and 115 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.25", "version": "0.2.26",
"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.", "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", "license": "MIT",
"type": "module", "type": "module",

View file

@ -205,6 +205,15 @@ export async function uploadSideOpModlist(
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
return { success: false, error: "Mod list file must be 10 MB or smaller." }; 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 buffer = Buffer.from(await file.arrayBuffer());
const created = await payload.create({ const created = await payload.create({

View file

@ -4,8 +4,6 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; 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 hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { transcodeToMp3 } from "@/lib/voiceover/transcode"; import { transcodeToMp3 } from "@/lib/voiceover/transcode";
@ -245,34 +243,6 @@ export async function reviewVoiceSubmission(
return { success: false, error: "The script for this submission no longer exists." }; 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({ await payload.update({
collection: "voice-submissions", collection: "voice-submissions",
id: submissionId, id: submissionId,
@ -284,65 +254,6 @@ export async function reviewVoiceSubmission(
depth: 0, 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 } }; return { success: true, data: { id: submissionId, status: decision } };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View file

@ -11,6 +11,7 @@ import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/component
import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField' import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField'
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription' import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage' 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 { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
@ -58,6 +59,7 @@ export const importMap = {
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb, "@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360, "@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825, "@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
"@/components/admin/voiceover/AudioPreviewField#AudioPreviewField": AudioPreviewField_6bccef9bf4ea41ed573ab9f36fa579b2,
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759, "@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,

View file

@ -63,13 +63,15 @@ export const VoiceScripts: CollectionConfig = {
name: "status", name: "status",
type: "select", type: "select",
required: true, required: true,
defaultValue: "open", defaultValue: "draft",
options: [ options: [
{ label: "Draft", value: "draft" },
{ label: "Open", value: "open" }, { label: "Open", value: "open" },
{ label: "Closed", value: "closed" }, { label: "Closed", value: "closed" },
], ],
admin: { admin: {
description: "Closed scripts reject new submissions", description:
"Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.",
}, },
}, },
], ],

View file

@ -1,7 +1,186 @@
import { CollectionConfig } from "payload"; import {
import type { User } from "@/payload-types"; CollectionConfig,
type CollectionAfterChangeHook,
type CollectionBeforeChangeHook,
} from "payload";
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { isSuperuser } from "@/utils/access-control/hasPermission"; 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 = { export const VoiceSubmissions: CollectionConfig = {
slug: "voice-submissions", slug: "voice-submissions",
@ -35,6 +214,16 @@ export const VoiceSubmissions: CollectionConfig = {
delete: ({ req }) => hasRoles(["developer"], req.user), delete: ({ req }) => hasRoles(["developer"], req.user),
}, },
fields: [ fields: [
{
name: "audioPreview",
type: "ui",
label: "Take audio",
admin: {
components: {
Field: "@/components/admin/voiceover/AudioPreviewField#AudioPreviewField",
},
},
},
{ {
name: "script", name: "script",
type: "relationship", type: "relationship",
@ -86,4 +275,8 @@ export const VoiceSubmissions: CollectionConfig = {
// scripts, …) stay blocked by Payload's built-in checks. // scripts, …) stay blocked by Payload's built-in checks.
upload: true, upload: true,
timestamps: true, timestamps: true,
hooks: {
beforeChange: [guardReviewTransition],
afterChange: [applyReviewSideEffects],
},
}; };

View file

@ -0,0 +1,64 @@
"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;

View file

@ -34,7 +34,6 @@ export function EodArena({
const clearedSectors = [ const clearedSectors = [
initialStats.bestTrainingTime, initialStats.bestTrainingTime,
initialStats.bestPerimeterTime, initialStats.bestPerimeterTime,
initialStats.bestSupplyRoadTime,
].filter((time) => time > 0).length; ].filter((time) => time > 0).length;
useEffect(() => { useEffect(() => {
@ -55,8 +54,8 @@ export function EodArena({
{!customUnlocked && ( {!customUnlocked && (
<UnlockNotice <UnlockNotice
title="custom minefields" title="custom minefields"
requirement="you have cleared Training Pad, FOB Perimeter, and Main Supply Road" requirement="you have cleared Training Pad and FOB Perimeter"
progress={`${clearedSectors} of 3 sectors cleared`} progress={`${clearedSectors} of 2 sectors cleared`}
/> />
)} )}
<MinesweeperBoard <MinesweeperBoard

View file

@ -20,6 +20,7 @@ import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired"; import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isEodUnlocked } from "@/lib/minigames/custom/eod";
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds"; import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
type Phase = "ready" | "playing" | "over"; type Phase = "ready" | "playing" | "over";
@ -227,8 +228,11 @@ export function MinesweeperBoard({
const longPressTimerRef = useRef<number | null>(null); const longPressTimerRef = useRef<number | null>(null);
const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty]; const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty];
const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig; const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig;
const customUnlocked = const customUnlocked = isEodUnlocked({
stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0; bestTrainingTime: stats.bestTrainingTime,
bestPerimeterTime: stats.bestPerimeterTime,
bestSupplyRoadTime: stats.bestSupplyRoadTime,
});
const isLargeCustomGame = const isLargeCustomGame =
difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD; difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD;
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0); const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
@ -812,7 +816,7 @@ export function MinesweeperBoard({
</div> </div>
) : !customUnlocked ? ( ) : !customUnlocked ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Clear all three standard locations to unlock custom minefields. Clear the Training Pad and FOB Perimeter to unlock custom minefields.
</p> </p>
) : null} ) : null}
</fieldset> </fieldset>

View file

@ -23,13 +23,9 @@ export interface EodUnlockStats {
bestSupplyRoadTime: number; bestSupplyRoadTime: number;
} }
/** A player unlocks the custom minefield editor once they cleared all three standard difficulties. */ /** A player unlocks the custom minefield editor once they cleared Training Pad and FOB Perimeter. */
export function isEodUnlocked(stats: EodUnlockStats): boolean { export function isEodUnlocked(stats: EodUnlockStats): boolean {
return ( return stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0;
stats.bestTrainingTime > 0 &&
stats.bestPerimeterTime > 0 &&
stats.bestSupplyRoadTime > 0
);
} }
export function validateMinefieldName(name: unknown): { error?: string; name: string } { export function validateMinefieldName(name: unknown): { error?: string; name: string } {

View file

@ -90,6 +90,7 @@ export async function listScriptsForUser(
const [scriptsRes, submissionsRes] = await Promise.all([ const [scriptsRes, submissionsRes] = await Promise.all([
payload.find({ payload.find({
collection: "voice-scripts", collection: "voice-scripts",
where: { status: { not_equals: "draft" } },
sort: "-createdAt", sort: "-createdAt",
limit: 0, limit: 0,
depth: 0, depth: 0,
@ -121,7 +122,7 @@ export async function getScriptForUser(
overrideAccess: true, overrideAccess: true,
}) })
.catch(() => null); .catch(() => null);
if (!script) return null; if (!script || script.status === "draft") return null;
const submissionsRes = await payload.find({ const submissionsRes = await payload.find({
collection: "voice-submissions", collection: "voice-submissions",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
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";`);
}

View file

@ -49,6 +49,7 @@ 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_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'; 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 = [ export const migrations = [
{ {
@ -304,6 +305,11 @@ export const migrations = [
{ {
up: migration_20260909_153427_add_opord_recall_best_times.up, up: migration_20260909_153427_add_opord_recall_best_times.up,
down: migration_20260909_153427_add_opord_recall_best_times.down, down: migration_20260909_153427_add_opord_recall_best_times.down,
name: '20260909_153427_add_opord_recall_best_times' 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'
}, },
]; ];

View file

@ -3157,9 +3157,9 @@ export interface VoiceScript {
*/ */
spoiler?: boolean | null; spoiler?: boolean | null;
/** /**
* Closed scripts reject new submissions * Draft scripts are hidden from the frontend. Open scripts accept submissions; closed scripts reject new ones.
*/ */
status: 'open' | 'closed'; status: 'draft' | 'open' | 'closed';
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }

View file

@ -465,7 +465,7 @@ describe("Custom minefields", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("Unlock gating", () => { describe("Unlock gating", () => {
it("locks custom minefields until a win is recorded on all three difficulties", () => { it("locks custom minefields until Training Pad and FOB Perimeter are cleared", () => {
expect( expect(
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }), isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
).toBe(false); ).toBe(false);
@ -473,18 +473,24 @@ describe("Custom minefields", () => {
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }), isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
).toBe(false); ).toBe(false);
expect( expect(
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }), isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
).toBe(false); ).toBe(false);
}); });
it("unlocks once every difficulty has a best time above zero", () => { it("unlocks once Training Pad and FOB Perimeter have best times above zero", () => {
expect( expect(
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 1 }), isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 0 }),
).toBe(true); ).toBe(true);
expect( expect(
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 120 }), isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
).toBe(true); ).toBe(true);
}); });
it("does not let a Main Supply Road win substitute for the required sectors", () => {
expect(
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 120 }),
).toBe(false);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------