1
0
Fork 0
polaris-task-force/src/payload.config.ts
Z8MB1E 5f06039697 feat(world): game map collections with basemaps, map permissions, and meter proximity
Add maps basemap config (world size, image/tiles mode, zoom defaults, isActive), map-roads, map-zones, and resource-nodes collections with maps:* permissions. Track game-structures map placement, meter coordinates, and label visibility/priority. Bump proximityThreshold to meters and adapt side-op map creation plus test fixtures to the new required fields.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:13:57 -04:00

606 lines
19 KiB
TypeScript

import { migrations } from "./migrations";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { FixedToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import path from "path";
import { buildConfig } from "payload";
import { fileURLToPath } from "url";
import sharp from "sharp";
import nodemailer from "nodemailer";
import { Users } from "@/collections/users/Users";
import { Roles } from "@/collections/users/Roles";
import { Awards } from "@/collections/users/Awards";
import { Media } from "@/collections/Media";
import { Ranks } from "@/collections/users/Ranks";
import { Profiles } from "@/collections/users/Profiles";
import { Missions } from "@/collections/intelligence/Missions";
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
import { Maps } from "@/collections/world/Maps";
import { MapRoads } from "@/collections/world/MapRoads";
import { MapZones } from "@/collections/world/MapZones";
import { ResourceNodes } from "@/collections/world/ResourceNodes";
import { Campaigns } from "@/collections/intelligence/Campaigns";
import { Qualifications } from "@/collections/users/Qualifications";
import { MissionFiles } from "@/collections/server/MissionFiles";
import { ModLists } from "@/collections/server/ModLists";
import { GameServers } from "@/collections/server/GameServers";
import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents";
import { ArmaCommands } from "@/collections/server/ArmaCommands";
import { RespawnTickets } from "@/collections/server/RespawnTickets";
import { Factions } from "@/collections/intelligence/Factions";
import { Assets } from "@/collections/logistics/Assets";
import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
import { Resources } from "@/collections/logistics/Resources";
import { Vehicles } from "@/collections/logistics/Vehicles";
import { Structures } from "@/collections/logistics/Structures";
import { Shipments } from "@/collections/logistics/Shipments";
import { Assignments } from "@/collections/users/Assignments";
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
import { GameHardResources } from "@/collections/game/GameHardResources";
import { GameEventLogs } from "@/collections/game/GameEventLogs";
import { GameVehicles } from "@/collections/game/GameVehicles";
import { GameNpcs } from "@/collections/game/GameNpcs";
import { NpcStaffing } from "@/collections/game/NpcStaffing";
import { LaborClassifications } from "@/collections/game/LaborClassifications";
import { Experience } from "@/collections/users/Experience";
import { BankAccounts } from "@/collections/banking/BankAccounts";
import { BankTransactions } from "@/collections/banking/BankTransactions";
import { LedgerEntries } from "@/collections/banking/LedgerEntries";
import { LockerStorages } from "@/collections/locker/LockerStorages";
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";
import { TicketVotes } from "@/collections/tickets/TicketVotes";
import { WikiPages } from "@/collections/wiki/WikiPages";
import { WikiRevisions } from "@/collections/wiki/WikiRevisions";
import { WikiTemplates } from "@/collections/wiki/WikiTemplates";
import { Projects } from "@/collections/projects/Projects";
import { Sprints } from "@/collections/projects/Sprints";
import { Releases } from "@/collections/projects/Releases";
import { Labels } from "@/collections/projects/Labels";
import { StoryBeats } from "@/collections/story/StoryBeats";
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
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 { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
import type { SeedPromptFunction } from "@ai-stack/payloadcms/types";
import { mcpPlugin } from "@payloadcms/plugin-mcp";
import { createOpenAI } from "@ai-sdk/openai";
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
const unslothProvider = createOpenAI({
apiKey: process.env.UNSLOTH_API_KEY,
baseURL: process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1",
});
const DEFAULT_MODEL = "unsloth/gemma-4-E4B-it-qat-GGUF";
// AI Compose allowlist: the plugin's default init makes an LLM call per field
// to seed prompts. Returning false here skips a field (no instruction row, no
// AI UI), and returning { data: { prompt } } (without a top-level `prompt` key)
// seeds the row statically, so no LLM call ever happens at boot and a wiped
// instructions table (e.g. after scripts/sync-prod.sh) rebuilds in
// milliseconds on the next startup.
const AI_TEXT_FIELD_ALLOWLIST = new Set([
"description",
"gamedescription",
"summary",
"bio",
"occupation",
"name",
"title",
]);
// Textarea/richText fields that are system-managed or technical, never AI-able.
const AI_FIELD_DENYLIST = new Set(["contentsnapshot", "html", "script"]);
const staticAiPrompt = (fieldName: string, fieldLabel: string, fieldType: string): string => {
switch (fieldName) {
case "description":
case "gamedescription":
return `Write an immersive, in-universe description for the "${fieldLabel}" of {{ title }}. 2-4 sentences of plain prose. No markdown, no lists, no headings.`;
case "summary":
return `Write a concise summary for the "${fieldLabel}" of {{ title }}. 1-3 sentences of plain prose.`;
case "bio":
return `Write a short in-universe biography for {{ title }}. A few sentences of plain prose covering background and personality.`;
case "occupation":
return `Suggest a short occupation label for {{ title }}. Reply with 1-3 words only, no explanation.`;
case "name":
return `Suggest a single fitting name for {{ title }}. Reply with the name only, no explanation.`;
case "title":
return `Suggest a single fitting title for {{ title }}. Reply with the title only, no explanation.`;
default:
if (fieldType === "richText") {
return `Write long-form content for the "${fieldLabel}" section of {{ title }}. Structured prose with an introduction, clear sections, and a conclusion.`;
}
return `Write suitable content for the "${fieldLabel}" of {{ title }}. Plain prose, concise and relevant.`;
}
};
const aiFieldSeedPrompts: SeedPromptFunction = ({ path, fieldType, fieldLabel }) => {
const fieldName = (path.split(".").pop() ?? "").toLowerCase();
if (AI_FIELD_DENYLIST.has(fieldName)) return false;
if (fieldType === "textarea" || fieldType === "richText") {
return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } };
}
if (fieldType === "text" && AI_TEXT_FIELD_ALLOWLIST.has(fieldName)) {
return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } };
}
return false;
};
function plainTextToLexicalJson(text: string): Record<string, unknown> {
const paragraphs = text.split(/\n\n+/).filter((p) => p.trim());
const children = paragraphs.map((para) => ({
type: "paragraph",
children: [
{
type: "text",
text: para.trim(),
format: 0,
style: "",
mode: "normal",
detail: 0,
direction: "ltr",
indent: 0,
version: 1,
},
],
direction: "ltr",
format: "start",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
}));
return {
root: {
type: "root",
children,
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
};
}
async function generatePlainText(prompt: string, system: string, model: string): Promise<string> {
const baseUrl = process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1";
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.UNSLOTH_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: system || "You are a helpful assistant." },
{ role: "user", content: prompt },
],
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Unsloth API error: ${response.statusText}`);
}
const result = await response.json();
return result.choices[0].message.content;
}
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const META_TITLE = "Polaris Task Force";
const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit.";
const collections = [
Media,
// Game
GameHardResources,
GameStructures,
GameVehicles,
GameNpcs,
NpcStaffing,
LaborClassifications,
GameEventLogs,
// Users
Users,
Roles,
Ranks,
Profiles,
Awards,
Qualifications,
Assignments,
AssignmentTransfers,
Experience,
UserNotifications,
Evaluations,
RibbonSubmissions,
// Intel
Missions,
MissionAttendances,
Campaigns,
Factions,
Technologies,
// Logistics
Assets,
Resources,
Vehicles,
Structures,
Shipments,
// Banking
BankAccounts,
BankTransactions,
LedgerEntries,
// Locker
LockerStorages,
Loadouts,
// Market
MarketListings,
MarketNegotiations,
MarketState,
// Voiceover
VoiceScripts,
VoiceSubmissions,
// Helpdesk
Tickets,
TicketVotes,
// Wiki
WikiPages,
WikiRevisions,
WikiTemplates,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World
Maps,
MapRoads,
MapZones,
ResourceNodes,
NarrativeEvents,
// Story
StoryBeats,
StoryBeatStates,
// Server
MissionFiles,
ModLists,
GameServers,
ArmaSyncEvents,
ArmaCommands,
RespawnTickets,
// Minigames
CustomMinefields,
CustomMinefieldScores,
CustomRadioTests,
CustomRadioTestScores,
];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
// to see/interact with a collection in the Payload admin panel. Non-admin
// (REST/API) reads keep the collection's original access behavior.
const scopedCollections = collections.map((collection) => ({
...collection,
access: {
...collection.access,
read: requireAdminPageAccess(collection.slug, collection.access?.read),
},
}));
// MCP: eligible per collection, not granted: `enabled: true` makes all CRUD
// capabilities available, but each MCP API key still picks its own capability
// checkboxes and each collection's access control still applies.
const mcpCollections = Object.fromEntries(
scopedCollections.map((collection) => [collection.slug, { enabled: true } as const]),
);
export default buildConfig({
// serverURL: "http://localhost:3000/",
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
meta: {
titleSuffix: "- Polaris Task Force",
applicationName: META_TITLE,
abstract: META_DESCRIPTION,
keywords: "Polaris Task Force, Polaris, Task Force, Polaris Task Force official website",
description: META_DESCRIPTION,
openGraph: {
title: META_TITLE,
description: META_DESCRIPTION,
url: "https://polaris-task-force.com",
siteName: META_TITLE,
images: [
{
url: "/images/icon-favicon.png",
width: 512,
height: 512,
alt: "Polaris Task Force Logo",
},
],
},
icons: [
{
rel: "icon",
type: "image/png",
url: "/images/icon-favicon.png",
},
],
},
components: {
beforeDashboard: ["@/components/static/BeforeDashboardText"],
header: ["@/components/static/VersionOverlay"],
beforeNav: ["@/components/static/GoToFrontendLink"],
graphics: {
Logo: "@/components/graphics/AppLogo",
Icon: "@/components/graphics/AppIcon",
},
},
},
globals: [GameRules, Shims],
collections: scopedCollections,
editor: lexicalEditor({
features: ({ defaultFeatures }) => {
return [...defaultFeatures, FixedToolbarFeature(), PayloadAiPluginLexicalEditorFeature()];
},
}),
secret: process.env.PAYLOAD_SECRET || "",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || "",
},
// push: process.env.NODE_ENV === "development",
push: false,
prodMigrations: migrations,
}),
sharp,
bin: [
{
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
key: "game-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
key: "market-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/missionTick.ts"),
key: "mission-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/serverTick.ts"),
key: "server-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/baseTick.ts"),
key: "base-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/economyTick.ts"),
key: "economy-tick",
},
{
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
key: "generate-mission",
},
],
plugins: [
// payloadCloudPlugin(),
// storage-adapter-placeholder
mcpPlugin({ collections: mcpCollections }),
payloadAiPlugin({
seedPrompts: aiFieldSeedPrompts,
collections: {
[GameHardResources.slug]: true,
[GameStructures.slug]: true,
[GameVehicles.slug]: true,
[GameNpcs.slug]: true,
[NpcStaffing.slug]: true,
[LaborClassifications.slug]: true,
[GameEventLogs.slug]: true,
// Users
[Users.slug]: true,
[Roles.slug]: true,
[Ranks.slug]: true,
[Profiles.slug]: true,
[Awards.slug]: true,
[Qualifications.slug]: true,
[Assignments.slug]: true,
[Experience.slug]: true,
[UserNotifications.slug]: true,
[Evaluations.slug]: true,
// Intel
[Missions.slug]: true,
[MissionAttendances.slug]: true,
[Campaigns.slug]: true,
[Factions.slug]: true,
[Technologies.slug]: true,
// Logistics
[Assets.slug]: true,
[Resources.slug]: true,
[Vehicles.slug]: true,
[Structures.slug]: true,
[Shipments.slug]: true,
// Banking
[BankAccounts.slug]: true,
[BankTransactions.slug]: true,
[LedgerEntries.slug]: true,
// Locker
[LockerStorages.slug]: true,
[Loadouts.slug]: true,
// Market
[MarketListings.slug]: true,
[MarketNegotiations.slug]: true,
// Helpdesk
[Tickets.slug]: true,
// Wiki
[WikiPages.slug]: true,
[WikiRevisions.slug]: true,
[WikiTemplates.slug]: true,
// Projects
[Projects.slug]: true,
[Sprints.slug]: true,
[Releases.slug]: true,
[Labels.slug]: true,
},
debugging: true,
generationModels: [
{
id: "unsloth-text",
name: "Unsloth",
fields: ["text", "textarea", "richText"],
handler: async (
prompt: string,
options: {
locale: string;
model: string;
system: string;
schema: Record<string, unknown>;
maxTokens?: number;
temperature?: number;
},
) => {
const model = options.model || DEFAULT_MODEL;
if (options.schema?.definitions) {
const text = await generatePlainText(prompt, options.system, model);
const lexicalJson = plainTextToLexicalJson(text);
const ts = new ReadableStream({
start(controller) {
controller.enqueue(JSON.stringify(lexicalJson));
controller.close();
},
});
return createTextStreamResponse({ textStream: ts });
}
const streamResult = streamObject({
maxOutputTokens: options.maxTokens || 5000,
model: unslothProvider(model),
onError: (error) => {
console.error("Unsloth generateObject: ", error);
},
prompt,
schema: jsonSchema(options.schema),
system: options.system,
temperature: options.temperature ?? 0.7,
providerOptions: {
openai: {
strictJsonSchema: true,
structuredOutputs: true,
},
},
});
const textStream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of streamResult.textStream) {
controller.enqueue(chunk);
}
if ((await streamResult.finishReason) === "length") {
controller.enqueue("\u0000");
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return createTextStreamResponse({ textStream });
},
generateText: async (prompt: string, system: string) => {
return generatePlainText(prompt, system, DEFAULT_MODEL);
},
output: "text",
settings: {
name: "unsloth-text-settings",
type: "group",
admin: {
condition(data) {
return data["model-id"] === "unsloth-text";
},
},
fields: [
{
name: "model",
type: "text",
defaultValue: DEFAULT_MODEL,
label: "Model",
},
],
label: "Unsloth Settings",
},
},
],
}),
],
email: nodemailerAdapter({
defaultFromAddress: process.env.EMAIL_FROM_ADDRESS || "",
defaultFromName: process.env.EMAIL_FROM_NAME || "",
transport: nodemailer.createTransport({
host: process.env.EMAIL_HOST || "",
port: Number(process.env.EMAIL_PORT) || 587,
auth: {
user: process.env.EMAIL_USERNAME || "",
pass: process.env.EMAIL_PASSWORD || "",
},
}),
}),
});