1
0
Fork 0
polaris-task-force/src/payload.config.ts
Z8MB1E c628f8a90b feat(missions): auto-complete past missions via mission-tick bin
Add the mission-tick Payload bin (external cron): sweeps missions
whose status is Scheduled/Active and whose scheduled calendar day
(classification.startDateTime, server-local) has fully passed and
sets them to Completed, emitting a mission:auto-complete event per
mission. Draft and terminal statuses are never touched; idempotent.
Notifies clients via the game-tick SSE path.

Logic lives in src/lib/intelligence/missionLifecycle.ts with an
integration test in tests/int/mission-lifecycle.int.spec.ts.
2026-08-25 12:30:04 -04:00

453 lines
13 KiB
TypeScript

// storage-adapter-import-placeholder
import { postgresAdapter } from "@payloadcms/db-postgres";
import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path";
import { buildConfig } from "payload";
import { fileURLToPath } from "url";
import sharp from "sharp";
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 { 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 { 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 { 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 { 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 { UserNotifications } from "@/collections/users/UserNotifications";
import { Tickets } from "@/collections/tickets/Tickets";
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 { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
import { Shims } from "@/collections/Shims";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
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";
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.";
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: [
Media,
// Game
GameHardResources,
GameStructures,
GameVehicles,
GameNpcs,
GameEventLogs,
// Users
Users,
Roles,
Ranks,
Profiles,
Awards,
Qualifications,
Assignments,
Experience,
UserNotifications,
// Intel
Missions,
MissionAttendances,
Campaigns,
Factions,
Technologies,
// Logistics
Assets,
Resources,
Vehicles,
Structures,
Shipments,
// Banking
BankAccounts,
BankTransactions,
LedgerEntries,
// Locker
LockerStorages,
Loadouts,
// Market
MarketListings,
MarketNegotiations,
// Helpdesk
Tickets,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World
Maps,
NarrativeEvents,
// Server
MissionFiles,
ModLists,
GameServers,
ArmaSyncEvents,
ArmaCommands,
],
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [HeadingFeature(), 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",
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/generateNextMission.ts"),
key: "generate-mission",
},
],
plugins: [
// payloadCloudPlugin(),
// storage-adapter-placeholder
mcpPlugin({}),
payloadAiPlugin({
collections: {
[GameHardResources.slug]: true,
[GameStructures.slug]: true,
[GameVehicles.slug]: true,
[GameNpcs.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,
// 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,
// 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 || "",
},
}),
}),
});