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:
parent
13f2132e25
commit
f83301a923
10 changed files with 23440 additions and 1 deletions
|
|
@ -32,6 +32,8 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Wiki Templates", value: "wiki-templates" },
|
{ label: "Wiki Templates", value: "wiki-templates" },
|
||||||
{ label: "Game Servers", value: "game-servers" },
|
{ label: "Game Servers", value: "game-servers" },
|
||||||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||||
|
{ label: "Voice Scripts", value: "voice-scripts" },
|
||||||
|
{ label: "Voice Submissions", value: "voice-submissions" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
|
||||||
radioTraffic?: { xpEarned?: number | null };
|
radioTraffic?: { xpEarned?: number | null };
|
||||||
aimTrainer?: { xpEarned?: number | null };
|
aimTrainer?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
|
voiceover?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
const awardsList = progressionData?.awards || [];
|
const awardsList = progressionData?.awards || [];
|
||||||
const awardIds = awardsList.flatMap((entry) => {
|
const awardIds = awardsList.flatMap((entry) => {
|
||||||
|
|
@ -145,6 +146,7 @@ export const Profiles: CollectionConfig = {
|
||||||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||||
|
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
await req.payload
|
await req.payload
|
||||||
|
|
@ -186,6 +188,8 @@ export const Profiles: CollectionConfig = {
|
||||||
xpAcc += eodXp;
|
xpAcc += eodXp;
|
||||||
xpAcc += radioTrafficXp;
|
xpAcc += radioTrafficXp;
|
||||||
xpAcc += aimTrainerXp;
|
xpAcc += aimTrainerXp;
|
||||||
|
// Voiceover submissions: XP awarded per accepted submission
|
||||||
|
xpAcc += voiceoverXp;
|
||||||
|
|
||||||
return xpAcc;
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
57
src/collections/voiceover/VoiceScripts.ts
Normal file
57
src/collections/voiceover/VoiceScripts.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
78
src/collections/voiceover/VoiceSubmissions.ts
Normal file
78
src/collections/voiceover/VoiceSubmissions.ts
Normal 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,
|
||||||
|
};
|
||||||
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal file
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -216,4 +217,9 @@ export const migrations = [
|
||||||
down: migration_20260906_165837_add_market_state_event_target.down,
|
down: migration_20260906_165837_add_market_state_event_target.down,
|
||||||
name: '20260906_165837_add_market_state_event_target',
|
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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ import { Loadouts } from "@/collections/locker/Loadouts";
|
||||||
import { MarketListings } from "@/collections/market/MarketListings";
|
import { MarketListings } from "@/collections/market/MarketListings";
|
||||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||||
import { MarketState } from "@/collections/market/MarketState";
|
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 { UserNotifications } from "@/collections/users/UserNotifications";
|
||||||
import { Evaluations } from "@/collections/users/Evaluations";
|
import { Evaluations } from "@/collections/users/Evaluations";
|
||||||
import { Tickets } from "@/collections/tickets/Tickets";
|
import { Tickets } from "@/collections/tickets/Tickets";
|
||||||
|
|
@ -199,6 +201,10 @@ const collections = [
|
||||||
MarketNegotiations,
|
MarketNegotiations,
|
||||||
MarketState,
|
MarketState,
|
||||||
|
|
||||||
|
// Voiceover
|
||||||
|
VoiceScripts,
|
||||||
|
VoiceSubmissions,
|
||||||
|
|
||||||
// Helpdesk
|
// Helpdesk
|
||||||
Tickets,
|
Tickets,
|
||||||
TicketVotes,
|
TicketVotes,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ interface GameEventInput {
|
||||||
| "wiki-templates"
|
| "wiki-templates"
|
||||||
| "game-servers"
|
| "game-servers"
|
||||||
| "assignment-transfers"
|
| "assignment-transfers"
|
||||||
| "market-state";
|
| "market-state"
|
||||||
|
| "voice-scripts"
|
||||||
|
| "voice-submissions";
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ export const EventTypes = {
|
||||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
MinigameAimTrainer: "minigame:aim-trainer",
|
MinigameAimTrainer: "minigame:aim-trainer",
|
||||||
|
|
||||||
|
// Voiceover
|
||||||
|
VoiceoverSubmit: "voiceover:submit",
|
||||||
|
VoiceoverAccepted: "voiceover:accepted",
|
||||||
|
VoiceoverRejected: "voiceover:rejected",
|
||||||
|
|
||||||
// Market
|
// Market
|
||||||
MarketListingCreate: "market:listing-create",
|
MarketListingCreate: "market:listing-create",
|
||||||
MarketListingCancel: "market:listing-cancel",
|
MarketListingCancel: "market:listing-cancel",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue