1
0
Fork 0

Compare commits

..

No commits in common. "42cbcbac3c3840cb58ec54212b8eae876bf0b360" and "cbe88c141b65a2c5d20aff089f357b7aff74104b" have entirely different histories.

16 changed files with 118 additions and 24815 deletions

View file

@ -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",

View file

@ -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({

View file

@ -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";

View file

@ -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,

View file

@ -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",
},
},
],

View file

@ -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],
},
};

View file

@ -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;

View file

@ -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

View file

@ -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>

View file

@ -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 } {

View file

@ -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",

View file

@ -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";`);
}

View file

@ -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'
},
];

View file

@ -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;
}

View file

@ -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);
});
});