1
0
Fork 0

feat(voiceover): add voice script and submission collections with profile XP

Add VoiceScripts and VoiceSubmissions collections, voiceover event types and event-log targets, the voice-submissions migration, and a voiceover XP group on Profiles counted toward total XP.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 17:22:50 -04:00
parent 13f2132e25
commit f83301a923
10 changed files with 23440 additions and 1 deletions

View file

@ -32,6 +32,8 @@ const TARGET_COLLECTIONS = [
{ label: "Wiki Templates", value: "wiki-templates" },
{ label: "Game Servers", value: "game-servers" },
{ label: "Assignment Transfers", value: "assignment-transfers" },
{ label: "Voice Scripts", value: "voice-scripts" },
{ label: "Voice Submissions", value: "voice-submissions" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
radioTraffic?: { xpEarned?: number | null };
aimTrainer?: { xpEarned?: number | null };
};
voiceover?: { xpEarned?: number | null };
};
const awardsList = progressionData?.awards || [];
const awardIds = awardsList.flatMap((entry) => {
@ -145,6 +146,7 @@ export const Profiles: CollectionConfig = {
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
if (awardIds.length > 0) {
await req.payload
@ -186,6 +188,8 @@ export const Profiles: CollectionConfig = {
xpAcc += eodXp;
xpAcc += radioTrafficXp;
xpAcc += aimTrainerXp;
// Voiceover submissions: XP awarded per accepted submission
xpAcc += voiceoverXp;
return xpAcc;
},
@ -563,6 +567,19 @@ export const Profiles: CollectionConfig = {
},
],
},
{
name: "voiceover",
label: "Voiceover",
type: "group",
fields: [
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
],
},
],
},
],

View file

@ -0,0 +1,57 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
export const VoiceScripts: CollectionConfig = {
slug: "voice-scripts",
admin: {
group: "Voiceover",
useAsTitle: "title",
defaultColumns: ["title", "xpAmount", "status", "createdAt"],
},
access: {
read: ({ req }) => Boolean(req.user),
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "script",
type: "textarea",
required: true,
admin: {
description:
"Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.",
},
},
{
name: "xpAmount",
label: "XP Amount",
type: "number",
required: true,
defaultValue: 250,
min: 0,
admin: {
description: "XP awarded per accepted submission of this script.",
},
},
{
name: "status",
type: "select",
required: true,
defaultValue: "open",
options: [
{ label: "Open", value: "open" },
{ label: "Closed", value: "closed" },
],
admin: {
description: "Closed scripts reject new submissions",
},
},
],
};

View file

@ -0,0 +1,78 @@
import { CollectionConfig } from "payload";
import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isSuperuser } from "@/utils/access-control/hasPermission";
export const VoiceSubmissions: CollectionConfig = {
slug: "voice-submissions",
admin: {
group: "Voiceover",
useAsTitle: "id",
defaultColumns: ["script", "user", "status", "createdAt"],
},
access: {
read: async ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
// Elevation bypasses the owner scope so reviewers see every submission;
// regular users only ever see their own.
if (await isSuperuser(req.payload, user)) return true;
if (hasRoles(["admin", "developer"], user)) return true;
return { user: { equals: user.id } };
},
create: ({ req, data }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
// Submitters may only create their own submissions. Server actions that
// write on behalf of users run with overrideAccess and set `user` themselves.
const submittedUser =
typeof data?.user === "object" && data?.user !== null ? data.user.id : data?.user;
return submittedUser === user.id;
},
// Status changes are review actions — submitters must not edit their own docs.
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
delete: ({ req }) => hasRoles(["developer"], req.user),
},
fields: [
{
name: "script",
type: "relationship",
relationTo: "voice-scripts",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
admin: {
description: "Submitter",
},
},
{
name: "status",
type: "select",
required: true,
defaultValue: "pending",
options: [
{ label: "Pending", value: "pending" },
{ label: "Accepted", value: "accepted" },
{ label: "Rejected", value: "rejected" },
],
},
{
name: "reviewNote",
type: "textarea",
admin: {
description: "Optional admin review note.",
},
},
],
upload: {
mimeTypes: ["audio/webm", "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a"],
},
timestamps: true,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_voice_scripts_status" AS ENUM('open', 'closed');
CREATE TYPE "public"."enum_voice_submissions_status" AS ENUM('pending', 'accepted', 'rejected');
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-scripts';
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-submissions';
CREATE TABLE "voice_scripts" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"script" varchar NOT NULL,
"xp_amount" numeric DEFAULT 250 NOT NULL,
"status" "enum_voice_scripts_status" DEFAULT 'open' NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "voice_submissions" (
"id" serial PRIMARY KEY NOT NULL,
"script_id" integer NOT NULL,
"user_id" integer NOT NULL,
"status" "enum_voice_submissions_status" DEFAULT 'pending' NOT NULL,
"review_note" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "profiles" ADD COLUMN "progression_voiceover_xp_earned" numeric DEFAULT 0;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_scripts_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_submissions_id" integer;
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_script_id_voice_scripts_id_fk" FOREIGN KEY ("script_id") REFERENCES "public"."voice_scripts"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "voice_scripts_updated_at_idx" ON "voice_scripts" USING btree ("updated_at");
CREATE INDEX "voice_scripts_created_at_idx" ON "voice_scripts" USING btree ("created_at");
CREATE INDEX "voice_submissions_script_idx" ON "voice_submissions" USING btree ("script_id");
CREATE INDEX "voice_submissions_user_idx" ON "voice_submissions" USING btree ("user_id");
CREATE INDEX "voice_submissions_updated_at_idx" ON "voice_submissions" USING btree ("updated_at");
CREATE INDEX "voice_submissions_created_at_idx" ON "voice_submissions" USING btree ("created_at");
CREATE UNIQUE INDEX "voice_submissions_filename_idx" ON "voice_submissions" USING btree ("filename");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk" FOREIGN KEY ("voice_scripts_id") REFERENCES "public"."voice_scripts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk" FOREIGN KEY ("voice_submissions_id") REFERENCES "public"."voice_submissions"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_voice_scripts_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_scripts_id");
CREATE INDEX "payload_locked_documents_rels_voice_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_submissions_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "voice_scripts" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "voice_submissions" DISABLE ROW LEVEL SECURITY;
DROP TABLE "voice_scripts" CASCADE;
DROP TABLE "voice_submissions" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk";
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
DROP TYPE "public"."enum_game_event_logs_target_collection";
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
DROP INDEX "payload_locked_documents_rels_voice_scripts_id_idx";
DROP INDEX "payload_locked_documents_rels_voice_submissions_id_idx";
ALTER TABLE "profiles" DROP COLUMN "progression_voiceover_xp_earned";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_scripts_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_submissions_id";
DROP TYPE "public"."enum_voice_scripts_status";
DROP TYPE "public"."enum_voice_submissions_status";`)
}

View file

@ -34,6 +34,7 @@ import * as migration_20260906_063753_add_personal_excerpt_richtext from './2026
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
export const migrations = [
{
@ -216,4 +217,9 @@ export const migrations = [
down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target',
},
{
up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down,
name: '20260906_202925_add_voice_submissions'
},
];

View file

@ -49,6 +49,8 @@ import { Loadouts } from "@/collections/locker/Loadouts";
import { MarketListings } from "@/collections/market/MarketListings";
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
import { MarketState } from "@/collections/market/MarketState";
import { VoiceScripts } from "@/collections/voiceover/VoiceScripts";
import { VoiceSubmissions } from "@/collections/voiceover/VoiceSubmissions";
import { UserNotifications } from "@/collections/users/UserNotifications";
import { Evaluations } from "@/collections/users/Evaluations";
import { Tickets } from "@/collections/tickets/Tickets";
@ -199,6 +201,10 @@ const collections = [
MarketNegotiations,
MarketState,
// Voiceover
VoiceScripts,
VoiceSubmissions,
// Helpdesk
Tickets,
TicketVotes,

View file

@ -36,7 +36,9 @@ interface GameEventInput {
| "wiki-templates"
| "game-servers"
| "assignment-transfers"
| "market-state";
| "market-state"
| "voice-scripts"
| "voice-submissions";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -77,6 +77,11 @@ export const EventTypes = {
MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer",
// Voiceover
VoiceoverSubmit: "voiceover:submit",
VoiceoverAccepted: "voiceover:accepted",
VoiceoverRejected: "voiceover:rejected",
// Market
MarketListingCreate: "market:listing-create",
MarketListingCancel: "market:listing-cancel",