1
0
Fork 0

feat(minigames): add custom minefield and radio test collections with permissions, events, and migration

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-08 14:38:49 -04:00
parent 0143c235bc
commit 1f99e34c0d
13 changed files with 25060 additions and 1 deletions

View file

@ -36,6 +36,10 @@ const TARGET_COLLECTIONS = [
{ label: "Voice Submissions", value: "voice-submissions" },
{ label: "Story Beats", value: "story-beats" },
{ label: "Story Beat States", value: "story-beat-states" },
{ label: "Custom Minefields", value: "custom-minefields" },
{ label: "Custom Minefield Scores", value: "custom-minefield-scores" },
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -0,0 +1,48 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const CustomMinefieldScores: CollectionConfig = {
slug: "custom-minefield-scores",
admin: {
group: "Minigames",
defaultColumns: ["minefield", "user", "seconds", "won", "createdAt"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: () => false,
delete: requirePermission("custom-minefield-scores:delete"),
},
fields: [
{
name: "minefield",
type: "relationship",
relationTo: "custom-minefields",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
},
{
name: "seconds",
type: "number",
required: true,
},
{
name: "flags",
type: "number",
defaultValue: 0,
},
{
name: "won",
type: "checkbox",
required: true,
},
],
timestamps: true,
};

View file

@ -0,0 +1,82 @@
import { CollectionConfig } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const CustomMinefields: CollectionConfig = {
slug: "custom-minefields",
admin: {
group: "Minigames",
useAsTitle: "name",
defaultColumns: ["name", "author", "rows", "cols", "mineCount", "published", "playCount"],
listSearchableFields: ["name"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "custom-minefields:update")) return true;
return { author: { equals: req.user.id } };
},
delete: requirePermission("custom-minefields:delete"),
},
fields: [
{
name: "name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "Player who authored this minefield.",
},
},
{
name: "rows",
type: "number",
required: true,
min: 9,
max: 500,
},
{
name: "cols",
type: "number",
required: true,
min: 9,
max: 500,
},
{
name: "mines",
type: "json",
required: true,
admin: {
description: "Array of { row, col } mine positions.",
},
},
{
name: "mineCount",
type: "number",
required: true,
min: 1,
max: 10000,
},
{
name: "published",
type: "checkbox",
defaultValue: true,
},
{
name: "playCount",
type: "number",
defaultValue: 0,
admin: {
readOnly: true,
},
},
],
timestamps: true,
};

View file

@ -0,0 +1,69 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const CustomRadioTestScores: CollectionConfig = {
slug: "custom-radio-test-scores",
admin: {
group: "Minigames",
defaultColumns: ["test", "user", "score", "accuracy", "mode", "createdAt"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: () => false,
delete: requirePermission("custom-radio-test-scores:delete"),
},
fields: [
{
name: "test",
type: "relationship",
relationTo: "custom-radio-tests",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
},
{
name: "score",
type: "number",
required: true,
},
{
name: "accuracy",
type: "number",
required: true,
},
{
name: "correctMessages",
type: "number",
required: true,
},
{
name: "incorrectMessages",
type: "number",
required: true,
},
{
name: "mode",
type: "select",
required: true,
options: [
{ label: "Sprint", value: "sprint" },
{ label: "Watch", value: "watch" },
],
},
{
name: "timeMs",
type: "number",
admin: {
description: "Sprint mode only: elapsed time in milliseconds.",
},
},
],
timestamps: true,
};

View file

@ -0,0 +1,77 @@
import { CollectionConfig } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const CustomRadioTests: CollectionConfig = {
slug: "custom-radio-tests",
admin: {
group: "Minigames",
useAsTitle: "name",
defaultColumns: ["name", "author", "messageCount", "order", "published", "playCount"],
listSearchableFields: ["name"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "custom-radio-tests:update")) return true;
return { author: { equals: req.user.id } };
},
delete: requirePermission("custom-radio-tests:delete"),
},
fields: [
{
name: "name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "Player who authored this radio test.",
},
},
{
name: "messages",
type: "json",
required: true,
admin: {
description: "Array of message strings used as the typing pool.",
},
},
{
name: "messageCount",
type: "number",
required: true,
min: 1,
max: 100,
},
{
name: "order",
type: "select",
defaultValue: "fixed",
options: [
{ label: "Fixed Order", value: "fixed" },
{ label: "Shuffled", value: "shuffled" },
],
},
{
name: "published",
type: "checkbox",
defaultValue: true,
},
{
name: "playCount",
type: "number",
defaultValue: 0,
admin: {
readOnly: true,
},
},
],
timestamps: true,
};

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -45,6 +45,7 @@ import * as migration_20260907_090245_add_mission_ready_reminder_fields from './
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
export const migrations = [
{
@ -282,4 +283,9 @@ export const migrations = [
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: '20260908_152021_add_opord_recall_minigame_stats'
},
{
up: migration_20260908_172241_add_custom_minigame_collections.up,
down: migration_20260908_172241_add_custom_minigame_collections.down,
name: '20260908_172241_add_custom_minigame_collections'
},
];

View file

@ -125,6 +125,10 @@ export interface Config {
'game-servers': GameServer;
'arma-sync-events': ArmaSyncEvent;
'arma-commands': ArmaCommand;
'custom-minefields': CustomMinefield;
'custom-minefield-scores': CustomMinefieldScore;
'custom-radio-tests': CustomRadioTest;
'custom-radio-test-scores': CustomRadioTestScore;
'plugin-ai-instructions': PluginAiInstruction;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
@ -198,6 +202,10 @@ export interface Config {
'game-servers': GameServersSelect<false> | GameServersSelect<true>;
'arma-sync-events': ArmaSyncEventsSelect<false> | ArmaSyncEventsSelect<true>;
'arma-commands': ArmaCommandsSelect<false> | ArmaCommandsSelect<true>;
'custom-minefields': CustomMinefieldsSelect<false> | CustomMinefieldsSelect<true>;
'custom-minefield-scores': CustomMinefieldScoresSelect<false> | CustomMinefieldScoresSelect<true>;
'custom-radio-tests': CustomRadioTestsSelect<false> | CustomRadioTestsSelect<true>;
'custom-radio-test-scores': CustomRadioTestScoresSelect<false> | CustomRadioTestScoresSelect<true>;
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
@ -1686,6 +1694,12 @@ export interface Role {
| 'tickets:staff'
| 'discord:staff'
| 'discord:announce'
| 'custom-minefields:update'
| 'custom-minefields:delete'
| 'custom-minefield-scores:delete'
| 'custom-radio-tests:update'
| 'custom-radio-tests:delete'
| 'custom-radio-test-scores:delete'
)[]
| null;
/**
@ -1888,6 +1902,10 @@ export interface GameEventLog {
| 'voice-submissions'
| 'story-beats'
| 'story-beat-states'
| 'custom-minefields'
| 'custom-minefield-scores'
| 'custom-radio-tests'
| 'custom-radio-test-scores'
)
| null;
targetId?: number | null;
@ -3692,6 +3710,101 @@ export interface ArmaCommand {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefields".
*/
export interface CustomMinefield {
id: number;
name: string;
/**
* Player who authored this minefield.
*/
author: number | User;
rows: number;
cols: number;
/**
* Array of { row, col } mine positions.
*/
mines:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
mineCount: number;
published?: boolean | null;
playCount?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefield-scores".
*/
export interface CustomMinefieldScore {
id: number;
minefield: number | CustomMinefield;
user: number | User;
seconds: number;
flags?: number | null;
won: boolean;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-tests".
*/
export interface CustomRadioTest {
id: number;
name: string;
/**
* Player who authored this radio test.
*/
author: number | User;
/**
* Array of message strings used as the typing pool.
*/
messages:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
messageCount: number;
order?: ('fixed' | 'shuffled') | null;
published?: boolean | null;
playCount?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-test-scores".
*/
export interface CustomRadioTestScore {
id: number;
test: number | CustomRadioTest;
user: number | User;
score: number;
accuracy: number;
correctMessages: number;
incorrectMessages: number;
mode: 'sprint' | 'watch';
/**
* Sprint mode only: elapsed time in milliseconds.
*/
timeMs?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions".
@ -4012,6 +4125,22 @@ export interface PayloadLockedDocument {
relationTo: 'arma-commands';
value: number | ArmaCommand;
} | null)
| ({
relationTo: 'custom-minefields';
value: number | CustomMinefield;
} | null)
| ({
relationTo: 'custom-minefield-scores';
value: number | CustomMinefieldScore;
} | null)
| ({
relationTo: 'custom-radio-tests';
value: number | CustomRadioTest;
} | null)
| ({
relationTo: 'custom-radio-test-scores';
value: number | CustomRadioTestScore;
} | null)
| ({
relationTo: 'plugin-ai-instructions';
value: number | PluginAiInstruction;
@ -5651,6 +5780,66 @@ export interface ArmaCommandsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefields_select".
*/
export interface CustomMinefieldsSelect<T extends boolean = true> {
name?: T;
author?: T;
rows?: T;
cols?: T;
mines?: T;
mineCount?: T;
published?: T;
playCount?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefield-scores_select".
*/
export interface CustomMinefieldScoresSelect<T extends boolean = true> {
minefield?: T;
user?: T;
seconds?: T;
flags?: T;
won?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-tests_select".
*/
export interface CustomRadioTestsSelect<T extends boolean = true> {
name?: T;
author?: T;
messages?: T;
messageCount?: T;
order?: T;
published?: T;
playCount?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-test-scores_select".
*/
export interface CustomRadioTestScoresSelect<T extends boolean = true> {
test?: T;
user?: T;
score?: T;
accuracy?: T;
correctMessages?: T;
incorrectMessages?: T;
mode?: T;
timeMs?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions_select".

View file

@ -68,6 +68,10 @@ import { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
import { Shims } from "@/collections/Shims";
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
import { mcpPlugin } from "@payloadcms/plugin-mcp";
@ -236,6 +240,12 @@ const collections = [
GameServers,
ArmaSyncEvents,
ArmaCommands,
// Minigames
CustomMinefields,
CustomMinefieldScores,
CustomRadioTests,
CustomRadioTestScores,
];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`

View file

@ -589,6 +589,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "discord:announce", label: "Post Announcements (/announce)" },
],
},
{
group: "Minigames",
permissions: [
{ value: "custom-minefields:update", label: "Edit Any Custom Minefield" },
{ value: "custom-minefields:delete", label: "Delete Custom Minefields" },
{ value: "custom-minefield-scores:delete", label: "Delete Custom Minefield Scores" },
{ value: "custom-radio-tests:update", label: "Edit Any Custom Radio Test" },
{ value: "custom-radio-tests:delete", label: "Delete Custom Radio Tests" },
{ value: "custom-radio-test-scores:delete", label: "Delete Custom Radio Test Scores" },
],
},
];
// ---------------------------------------------------------------------------
@ -855,6 +866,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"tickets:staff",
"discord:staff",
"discord:announce",
"custom-minefields:update",
"custom-minefields:delete",
"custom-minefield-scores:delete",
"custom-radio-tests:update",
"custom-radio-tests:delete",
"custom-radio-test-scores:delete",
];
/**

View file

@ -40,7 +40,11 @@ interface GameEventInput {
| "voice-scripts"
| "voice-submissions"
| "story-beats"
| "story-beat-states";
| "story-beat-states"
| "custom-minefields"
| "custom-minefield-scores"
| "custom-radio-tests"
| "custom-radio-test-scores";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -77,6 +77,14 @@ export const EventTypes = {
MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer",
MinigameOpordRecall: "minigame:opord-recall",
MinigameCustomEodCreate: "minigame:custom-eod-create",
MinigameCustomEodEdit: "minigame:custom-eod-edit",
MinigameCustomEodDelete: "minigame:custom-eod-delete",
MinigameCustomEodPlay: "minigame:custom-eod-play",
MinigameCustomRadioCreate: "minigame:custom-radio-create",
MinigameCustomRadioEdit: "minigame:custom-radio-edit",
MinigameCustomRadioDelete: "minigame:custom-radio-delete",
MinigameCustomRadioPlay: "minigame:custom-radio-play",
// Voiceover
VoiceoverSubmit: "voiceover:submit",