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.
453 lines
13 KiB
TypeScript
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 || "",
|
|
},
|
|
}),
|
|
}),
|
|
});
|