Compare commits
15 commits
98f2b0447a
...
d6b2ffa5d2
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b2ffa5d2 | |||
| f1dd3c67ce | |||
| 802873c7fa | |||
| fff9e82d62 | |||
| 41b9d62e15 | |||
| 3348466f3b | |||
| 7cfb650095 | |||
| 9b909e85e6 | |||
| c628f8a90b | |||
| de1230a757 | |||
| e72b849774 | |||
| 824b03ec49 | |||
| c0d00fc113 | |||
| b70ff4dff8 | |||
| 83790a629b |
49 changed files with 1961 additions and 1045 deletions
15
.env.example
15
.env.example
|
|
@ -22,6 +22,13 @@ APP_URL=http://localhost:3000
|
|||
# Coolify (or to your cron). See DEPLOYMENT.md.
|
||||
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET
|
||||
|
||||
# --- Bin script file logging ---
|
||||
# Optional. Payload bin scripts (game-tick, market-tick, mission-tick,
|
||||
# generate-mission) tee their logs to daily files. Defaults to <repo>/logs
|
||||
# (inside containers: /app/logs — mount a persistent volume or point this at
|
||||
# a persisted path, e.g. /app/media/logs, to keep them across restarts).
|
||||
# BIN_LOG_DIR=
|
||||
|
||||
# --- Payload email (nodemailer / SMTP relay) ---
|
||||
# Optional. Leave blank to disable outbound email (password resets, etc).
|
||||
EMAIL_FROM_ADDRESS=
|
||||
|
|
@ -50,4 +57,10 @@ APP_PORT=3000
|
|||
# DISCORD_ANNOUNCE_CHANNEL_ID=
|
||||
# DISCORD_STAFF_ROLE_IDS=
|
||||
# DISCORD_ATTENDANCE_POLL_MS=
|
||||
# DISCORD_NOTIFICATION_POLL_MS=
|
||||
# DISCORD_NOTIFICATION_POLL_MS=
|
||||
|
||||
# --- Arma 3 bridge (Pythia HTTP integration) ---
|
||||
# Shared secret required by the /api/arma/v1/* routes. The Arma server sends
|
||||
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
|
||||
# with: openssl rand -hex 32. Leave unset to disable the bridge (routes 503).
|
||||
# ARMA_BRIDGE_API_KEY=
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -31,6 +31,9 @@ npm-debug.log*
|
|||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
|
||||
/logs/
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
|
|||
|
||||
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
|
||||
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
|
||||
- **Bin file logging**: all four bins (`game-tick`, `market-tick`, `mission-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
||||
- **Mission auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Sweeps missions whose status is `Scheduled`/`Active` and whose scheduled date (`classification.startDateTime`) has fully passed — from the start of the following **server-local** day — and sets them to `Completed`, emitting a `mission:auto-complete` event per mission (logic: `src/lib/intelligence/missionLifecycle.ts`, test: `tests/int/mission-lifecycle.int.spec.ts`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged.
|
||||
- **GameRules tuning**: `proximityThreshold` and `gameTickIntervalMinutes` live on the global `game-rules` doc.
|
||||
- **UI**: `src/app/(frontend)/logistics/shipments/` (list + `[id]` detail with `ShipmentActions` controls), `src/app/(frontend)/logistics/game-vehicles/` (deployed vehicle views).
|
||||
|
|
|
|||
|
|
@ -57,10 +57,13 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied).
|
|||
| `bun run payload <bin>` | Run Payload binaries |
|
||||
| `bun run payload game-tick` | Process shipment arrivals and fuel consumption |
|
||||
| `bun run payload market-tick` | Expire listings and refresh NPC vendor stock |
|
||||
| `bun run payload mission-tick` | Auto-complete missions whose scheduled day has passed |
|
||||
| `bun run deploy` | Bump patch version, build, and deploy |
|
||||
|
||||
Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`.
|
||||
|
||||
All four bin scripts also log to daily files under `logs/<bin-key>/` (e.g. `logs/game-tick/2026-08-24.log`) so tick output can be tracked over time. Override the directory with the `BIN_LOG_DIR` env var.
|
||||
|
||||
For an Arma 3 unit deployment we self-host on **Coolify** with one container per environment (dev / stg / prod), a Coolify-managed PostgreSQL service per environment, a persistent volume for `/app/media`, and `docker exec` scheduled jobs for the game tick. The Dockerfile is Bun-based (multi-stage, Next.js standalone runtime + Payload CLI kept at runtime), and `docker-compose.yml` mirrors the same shape for local full-stack dev.
|
||||
|
||||
For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
||||
|
|
|
|||
43
package.json
43
package.json
|
|
@ -24,7 +24,8 @@
|
|||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
|
||||
"deploy": "bun pm version patch --message \"chore: update to %s\"",
|
||||
"preversion": "bun run build",
|
||||
"postversion": "bun run generate:version-info && bash ./build/deploy.sh"
|
||||
"postversion": "bun run generate:version-info && bash ./build/deploy.sh",
|
||||
"u:reset-password": "bun run src/tools/cli/reset-password.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-stack/payloadcms": "^3.2.30",
|
||||
|
|
@ -35,62 +36,64 @@
|
|||
"@payloadcms/email-nodemailer": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/payload-cloud": "3.88.0",
|
||||
"@payloadcms/plugin-mcp": "^3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-avatar": "^1.2.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-context-menu": "^2.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.22",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"discord.js": "^14.27.0",
|
||||
"dotenv": "17.2.3",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql": "^16.14.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.3",
|
||||
"nodemailer": "^9.0.5",
|
||||
"payload": "3.88.0",
|
||||
"postcss": "8.5.18",
|
||||
"prompt": "^1.3.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sharp": "0.34.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0",
|
||||
"@testing-library/react": "16.3.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/node": "^25.9.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9.39.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"jsdom": "27.3.0",
|
||||
"playwright": "1.57.0",
|
||||
"playwright-core": "1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.9.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-tsconfig-paths": "6.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
|
@ -23,7 +24,7 @@ async function authenticate() {
|
|||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import type { Ticket } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -25,7 +26,7 @@ async function authenticate() {
|
|||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getPayload } from "payload";
|
|||
import { ShieldIcon } from "lucide-react";
|
||||
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
|
||||
import type { Media } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const metadata = {
|
||||
title: "Campaigns — Polaris Task Force",
|
||||
|
|
@ -71,17 +72,27 @@ export default async function CampaignsPage() {
|
|||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Campaigns</h1>
|
||||
<div className="flex flex-row justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Campaigns</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaigns.length} campaign{campaigns.length !== 1 ? "s" : ""} across all theaters of
|
||||
operation.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaigns.length} campaign{campaigns.length !== 1 ? "s" : ""} across all theaters of
|
||||
operation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 justify-end text-center max-w-3xs">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you a Zeus and want to start your own campaign?
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
Create your own campaign!
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sorted.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{campaignsWithStats.map((campaign) => (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import {
|
|||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
||||
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { redactText } from "@/lib/redaction";
|
||||
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
||||
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||
|
|
@ -138,6 +140,28 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
const canManage = currentUser
|
||||
? await hasPermission(payload, currentUser, "missions:update")
|
||||
: false;
|
||||
|
||||
const authorOrZeusIds = [
|
||||
...(mission.ownershipAndStatus.authors ?? []),
|
||||
...(mission.ownershipAndStatus.zeus ?? []),
|
||||
].map((u) => (typeof u === "object" ? u.id : u));
|
||||
const canReveal =
|
||||
canManage || (currentUser ? authorOrZeusIds.includes(currentUser.id as number) : false);
|
||||
|
||||
const objectives = (mission.missionObjectives ?? []).map((obj) =>
|
||||
obj.redacted && !canReveal
|
||||
? {
|
||||
...obj,
|
||||
title: redactText(obj.title) ?? obj.title,
|
||||
description: redactText(obj.description),
|
||||
successOrFailConditions: redactText(obj.successOrFailConditions),
|
||||
}
|
||||
: obj,
|
||||
);
|
||||
|
||||
const [attendance, userAttendance] = await Promise.all([
|
||||
getMissionAttendance(payload, mission.id),
|
||||
currentUser
|
||||
|
|
@ -289,7 +313,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
|
||||
<MissionBriefingSection briefing={mission.briefing ?? null} />
|
||||
|
||||
<MissionObjectives objectives={mission.missionObjectives ?? null} />
|
||||
<MissionObjectives
|
||||
objectives={objectives}
|
||||
missionId={mission.id}
|
||||
isEditable={canManage}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
|
||||
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function toggleObjectiveRedacted(input: {
|
||||
missionId: number;
|
||||
objectiveIndex: number;
|
||||
redacted: boolean;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
// Check permissions - only admins/developers can toggle redacted flags
|
||||
const hasAccess = await hasPermission(payload, user, "missions:update");
|
||||
if (!hasAccess) {
|
||||
return { success: false, error: "You do not have permission to modify mission objectives." };
|
||||
}
|
||||
|
||||
// Get current mission data
|
||||
const mission = await payload.findByID({
|
||||
collection: "missions",
|
||||
id: input.missionId,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
if (!mission) {
|
||||
return { success: false, error: "Mission not found." };
|
||||
}
|
||||
|
||||
// Ensure objectives array exists and has the requested index
|
||||
const objectives = mission.missionObjectives || [];
|
||||
if (input.objectiveIndex < 0 || input.objectiveIndex >= objectives.length) {
|
||||
return { success: false, error: "Invalid objective index." };
|
||||
}
|
||||
|
||||
// Update the redacted flag for the specific objective
|
||||
const updatedObjectives = [...objectives];
|
||||
updatedObjectives[input.objectiveIndex] = {
|
||||
...updatedObjectives[input.objectiveIndex],
|
||||
redacted: input.redacted,
|
||||
};
|
||||
|
||||
// Update the mission with the new objectives array
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: input.missionId,
|
||||
data: {
|
||||
missionObjectives: updatedObjectives,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
// Emit an event for the objective update
|
||||
await emitGameEvent(payload, {
|
||||
type: "mission:objective-redacted-toggle",
|
||||
message: `Objective redacted flag toggled for mission ${mission.name}`,
|
||||
actor: user.id,
|
||||
targetCollection: "missions",
|
||||
targetId: input.missionId,
|
||||
data: {
|
||||
objectiveIndex: input.objectiveIndex,
|
||||
redacted: input.redacted,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|||
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { SiteHeader } from "@/components/frontend/SiteHeader";
|
||||
import { Metadata } from "next";
|
||||
|
|
@ -50,7 +51,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
||||
|
||||
let xpLevel: ResolvedLevel = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import type { Asset, Loadout, LockerStorage, User } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -39,7 +40,7 @@ async function authenticate() {
|
|||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type { Asset, Loadout, LockerStorage } from "@/payload-types";
|
||||
|
|
@ -13,10 +14,11 @@ export const metadata = {
|
|||
|
||||
export default async function LockerPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
|
||||
const userId = user?.id as number;
|
||||
const locker = await ensureLockerStorage(payload, userId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -31,7 +32,7 @@ async function authenticate() {
|
|||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types";
|
||||
|
|
@ -13,10 +14,11 @@ export const metadata = {
|
|||
|
||||
export default async function BankingPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
|
||||
const accountsRes = await payload.find({
|
||||
collection: "bank-accounts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -37,7 +38,7 @@ async function authenticate() {
|
|||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
? convertLexicalToPlaintext({ data: structureType.description })
|
||||
: null;
|
||||
|
||||
const image =
|
||||
typeof structureType.image === "object" && structureType.image !== null
|
||||
? structureType.image
|
||||
: null;
|
||||
const imageUrl = image?.url ?? null;
|
||||
|
||||
const totalValue = storedResources.reduce((sum, entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return sum;
|
||||
|
|
@ -80,22 +86,49 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
<header
|
||||
className={`relative flex flex-col gap-3 overflow-hidden rounded-lg${
|
||||
imageUrl ? " min-h-[280px] justify-between border border-border p-5" : ""
|
||||
}`}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={structure.name}
|
||||
fetchPriority="high"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-background" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/30 via-transparent to-black/40" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
href="/logistics"
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
|
||||
className="relative z-10 text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3" />
|
||||
Back to Structures
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="relative z-10 flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2Icon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">{structure.name}</h1>
|
||||
<Building2Icon
|
||||
className={`size-5 ${imageUrl ? "text-white/80" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<h1
|
||||
className={`text-lg font-semibold ${imageUrl ? "text-white drop-shadow-md" : ""}`}
|
||||
>
|
||||
{structure.name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
imageUrl ? "text-white/70" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{structureType.name}</span>
|
||||
<span>·</span>
|
||||
<span>{getCategoryLabel(structureType.category)}</span>
|
||||
|
|
@ -116,7 +149,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
{user && <ManageStorageButton structureId={structure.id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type { Media, Rank, User } from "@/payload-types";
|
||||
|
|
@ -34,7 +35,8 @@ export type RosterAssignment = {
|
|||
export default async function RosterPage() {
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const currentUser = isPayloadUser(authUser) ? authUser : null;
|
||||
|
||||
const [profileRes, assignmentRes] = await Promise.all([
|
||||
payload.find({
|
||||
|
|
|
|||
|
|
@ -1,23 +1,42 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
|
||||
import { asJson } from "@/lib/arma-bridge/json";
|
||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const serverId = authenticateArmaBridge(req);
|
||||
if (serverId instanceof NextResponse) return serverId;
|
||||
const body = await req.json().catch(() => null) as { id?: unknown; success?: unknown; result?: unknown; error?: unknown } | null;
|
||||
if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") return NextResponse.json({ error: "id and success are required." }, { status: 400 });
|
||||
const payload = await getPayload({ config });
|
||||
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true });
|
||||
const server = servers.docs[0];
|
||||
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 });
|
||||
const commands = await payload.find({ collection: "arma-commands", where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] }, limit: 1, overrideAccess: true });
|
||||
const auth = await requireArmaServer(req);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
const { payload, server } = auth;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as
|
||||
| { id?: unknown; success?: unknown; result?: unknown; error?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") {
|
||||
return NextResponse.json({ error: "id and success are required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const commands = await payload.find({
|
||||
collection: "arma-commands",
|
||||
where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const command = commands.docs[0];
|
||||
if (!command) return NextResponse.json({ error: "Command not found." }, { status: 404 });
|
||||
await payload.update({ collection: "arma-commands", id: command.id, data: { status: body.success ? "succeeded" : "failed", result: body.result ?? {}, error: typeof body.error === "string" ? body.error : undefined, completedAt: new Date().toISOString() }, overrideAccess: true });
|
||||
|
||||
await payload.update({
|
||||
collection: "arma-commands",
|
||||
id: command.id,
|
||||
data: {
|
||||
status: body.success ? "succeeded" : "failed",
|
||||
result: asJson(body.result),
|
||||
error: typeof body.error === "string" ? body.error : undefined,
|
||||
completedAt: new Date().toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
|
||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const serverId = authenticateArmaBridge(req);
|
||||
if (serverId instanceof NextResponse) return serverId;
|
||||
const payload = await getPayload({ config });
|
||||
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true });
|
||||
const server = servers.docs[0];
|
||||
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 });
|
||||
const commands = await payload.find({ collection: "arma-commands", where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] }, limit: 20, sort: "createdAt", overrideAccess: true });
|
||||
const auth = await requireArmaServer(req);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
const { payload, server } = auth;
|
||||
|
||||
const commands = await payload.find({
|
||||
collection: "arma-commands",
|
||||
where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] },
|
||||
limit: 20,
|
||||
sort: "createdAt",
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const deliveredAt = new Date().toISOString();
|
||||
await Promise.all(commands.docs.map((command) => payload.update({ collection: "arma-commands", id: command.id, data: { status: "delivered", deliveredAt }, overrideAccess: true })));
|
||||
return NextResponse.json({ commands: commands.docs.map((command) => ({ id: command.commandId, type: command.type, payload: command.payload })) });
|
||||
await Promise.all(
|
||||
commands.docs.map((command) =>
|
||||
payload.update({
|
||||
collection: "arma-commands",
|
||||
id: command.id,
|
||||
data: { status: "delivered", deliveredAt },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
commands: commands.docs.map((command) => ({
|
||||
id: command.commandId,
|
||||
type: command.type,
|
||||
payload: command.payload,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,83 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
|
||||
import { asJson } from "@/lib/arma-bridge/json";
|
||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface IncomingEvent {
|
||||
eventId: string;
|
||||
server: number;
|
||||
type: string;
|
||||
occurredAt: string;
|
||||
payload: ReturnType<typeof asJson>;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const serverId = authenticateArmaBridge(req);
|
||||
if (serverId instanceof NextResponse) return serverId;
|
||||
const body = await req.json().catch(() => null) as { events?: unknown[] } | null;
|
||||
if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) return NextResponse.json({ error: "events must contain at most 100 entries." }, { status: 400 });
|
||||
const payload = await getPayload({ config });
|
||||
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true });
|
||||
const server = servers.docs[0];
|
||||
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 });
|
||||
let accepted = 0;
|
||||
let duplicates = 0;
|
||||
const auth = await requireArmaServer(req);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
const { payload, server } = auth;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { events?: unknown[] } | null;
|
||||
if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "events must be an array containing at most 100 entries." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate + normalize; entries missing a string id or type are rejected.
|
||||
const valid: IncomingEvent[] = [];
|
||||
let rejected = 0;
|
||||
for (const rawEvent of body.events) {
|
||||
const event = rawEvent as Record<string, unknown>;
|
||||
if (!event || typeof event.id !== "string" || typeof event.type !== "string") continue;
|
||||
const eventId = `${serverId}:${event.id}`;
|
||||
const existing = await payload.find({ collection: "arma-sync-events", where: { eventId: { equals: eventId } }, limit: 1, overrideAccess: true });
|
||||
if (existing.docs.length) { duplicates++; continue; }
|
||||
await payload.create({ collection: "arma-sync-events", data: { eventId, server: server.id, type: event.type, occurredAt: typeof event.occurredAt === "string" ? event.occurredAt : new Date().toISOString(), payload: event.payload ?? {} }, overrideAccess: true });
|
||||
accepted++;
|
||||
if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
valid.push({
|
||||
eventId: `${server.serverId}:${event.id}`,
|
||||
server: server.id,
|
||||
type: event.type,
|
||||
occurredAt:
|
||||
typeof event.occurredAt === "string" ? event.occurredAt : new Date().toISOString(),
|
||||
payload: asJson(event.payload),
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true, accepted, duplicates });
|
||||
|
||||
// One batched lookup for events that already exist (idempotency by eventId).
|
||||
const existing = valid.length
|
||||
? await payload.find({
|
||||
collection: "arma-sync-events",
|
||||
where: { eventId: { in: valid.map((event) => event.eventId) } },
|
||||
limit: valid.length,
|
||||
overrideAccess: true,
|
||||
})
|
||||
: { docs: [] };
|
||||
const seen = new Set(existing.docs.map((doc) => doc.eventId));
|
||||
|
||||
let accepted = 0;
|
||||
let duplicates = 0;
|
||||
for (const event of valid) {
|
||||
// Also catches the same id repeated within this batch.
|
||||
if (seen.has(event.eventId)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await payload.create({ collection: "arma-sync-events", data: event, overrideAccess: true });
|
||||
seen.add(event.eventId);
|
||||
accepted++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/duplicate key|unique constraint/i.test(message)) {
|
||||
// Unique-constraint race with a concurrent request — already stored.
|
||||
duplicates++;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, accepted, duplicates, rejected });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import config from "@payload-config";
|
|||
import { getPayload } from "payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
|
||||
import { asJson } from "@/lib/arma-bridge/json";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -9,10 +10,36 @@ export const dynamic = "force-dynamic";
|
|||
export async function POST(req: NextRequest) {
|
||||
const serverId = authenticateArmaBridge(req);
|
||||
if (serverId instanceof NextResponse) return serverId;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { name?: unknown; metadata?: unknown } | null;
|
||||
const payload = await getPayload({ config });
|
||||
const found = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true });
|
||||
const data = { name: typeof body.name === "string" ? body.name : serverId, status: "online" as const, lastSeenAt: new Date().toISOString(), metadata: body.metadata ?? {} };
|
||||
const server = found.docs[0] ? await payload.update({ collection: "game-servers", id: found.docs[0].id, data, overrideAccess: true }) : await payload.create({ collection: "game-servers", data: { serverId, ...data }, overrideAccess: true });
|
||||
|
||||
const found = await payload.find({
|
||||
collection: "game-servers",
|
||||
where: { serverId: { equals: serverId } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const data = {
|
||||
name: typeof body?.name === "string" ? body.name : serverId,
|
||||
status: "online" as const,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
metadata: asJson(body?.metadata),
|
||||
};
|
||||
|
||||
const server = found.docs[0]
|
||||
? await payload.update({
|
||||
collection: "game-servers",
|
||||
id: found.docs[0].id,
|
||||
data,
|
||||
overrideAccess: true,
|
||||
})
|
||||
: await payload.create({
|
||||
collection: "game-servers",
|
||||
data: { serverId, ...data },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, serverId: server.id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import config from "@payload-config";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getPayload } from "payload";
|
||||
import { removePresence, touchPresence } from "@/lib/realtime/presence";
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ export async function POST(req: NextRequest) {
|
|||
const channel = channelFrom(req);
|
||||
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
|
||||
const { user } = await authenticate(req);
|
||||
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
if (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
touchPresence(channel, {
|
||||
id: user.id as number,
|
||||
name: user.payloadDisplayName || user.username || `Pilot #${user.id}`,
|
||||
|
|
@ -32,7 +33,7 @@ export async function DELETE(req: NextRequest) {
|
|||
const channel = channelFrom(req);
|
||||
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
|
||||
const { user } = await authenticate(req);
|
||||
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
if (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
|
||||
removePresence(channel, user.id as number);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,9 +143,9 @@ export const renderAttendanceEmbed = (
|
|||
.setTitle(missionTitle(mission))
|
||||
.setDescription(
|
||||
[
|
||||
`**Operation type:** ${operationTypeLabel(mission.operationType)}`,
|
||||
`**Operation Type:** ${operationTypeLabel(mission.operationType)}`,
|
||||
`**Starts:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
|
||||
`**Max players:** ${mission.missionRoles.maxPlayers}`,
|
||||
`**Max Players:** ${mission.missionRoles.maxPlayers}`,
|
||||
].join("\n"),
|
||||
)
|
||||
.setColor(EMBED_COLOR)
|
||||
|
|
@ -385,7 +385,9 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
export const start = (client: Client, payload: Payload): void => {
|
||||
payload.logger.info("[Bot] Attendance embed service started");
|
||||
void startAttendanceListener(client, payload).catch((error) => {
|
||||
payload.logger.error(`[Bot] Attendance realtime listener failed to start: ${errorMessage(error)}`);
|
||||
payload.logger.error(
|
||||
`[Bot] Attendance realtime listener failed to start: ${errorMessage(error)}`,
|
||||
);
|
||||
});
|
||||
void reconcile(client, payload);
|
||||
setInterval(() => {
|
||||
|
|
|
|||
|
|
@ -437,6 +437,16 @@ export const Missions: CollectionConfig = {
|
|||
type: "textarea",
|
||||
label: "Success / Fail Conditions",
|
||||
},
|
||||
{
|
||||
name: "redacted",
|
||||
type: "checkbox",
|
||||
label: "Redacted",
|
||||
admin: {
|
||||
description:
|
||||
"If true, this objective will be hidden from players until revealed.",
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
|
||||
export const Technologies: CollectionConfig = {
|
||||
slug: "technologies",
|
||||
|
|
@ -32,7 +33,9 @@ export const Technologies: CollectionConfig = {
|
|||
],
|
||||
defaultValue: "in_progress",
|
||||
filterOptions: ({ options, req }) => {
|
||||
const roles = (req.user?.roles as string[] | undefined) ?? [];
|
||||
const roles = isPayloadUser(req.user)
|
||||
? ((req.user.roles as string[] | undefined) ?? [])
|
||||
: [];
|
||||
const canReadAll = roles.includes("admin") || roles.includes("developer");
|
||||
if (canReadAll) return options;
|
||||
return options.filter((option) =>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Warehouse,
|
||||
Maximize,
|
||||
Minimize,
|
||||
SparkleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||
|
|
@ -29,7 +30,7 @@ import {
|
|||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -177,7 +178,7 @@ export function AppSidebar({
|
|||
preferences?: NonNullable<User["preferences"]>;
|
||||
} & React.ComponentProps<typeof Sidebar>) {
|
||||
const [isMiniMode, setIsMiniMode] = React.useState<boolean>(
|
||||
preferences?.display?.xpDisplayMini ?? false
|
||||
preferences?.display?.xpDisplayMini ?? false,
|
||||
);
|
||||
|
||||
// Re-sync when the saved preference changes (e.g. account page save + router.refresh()).
|
||||
|
|
@ -276,12 +277,9 @@ export function AppSidebar({
|
|||
<div className="flex items-center justify-between">
|
||||
{isMiniMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{xpLevel.levelName}
|
||||
</div>
|
||||
<span className="text-xs font-bold">
|
||||
{xpLevel.xp.toLocaleString()}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground uppercase">{xpLevel.levelName}</div>
|
||||
<SparkleIcon className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">{xpLevel.xp.toLocaleString()}</span>
|
||||
<Progress value={xpLevel.progress} className="w-20 h-1" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,79 @@
|
|||
import { CheckIcon, CrosshairIcon, XIcon } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckIcon, CrosshairIcon, EyeIcon, EyeOffIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { toggleObjectiveRedacted } from "@/app/(frontend)/intelligence/missions/objectives/actions";
|
||||
import { redactText } from "@/lib/redaction";
|
||||
|
||||
interface Objective {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
missionCritical?: boolean | null;
|
||||
successOrFailConditions?: string | null;
|
||||
redacted?: boolean | null;
|
||||
}
|
||||
|
||||
interface MissionObjectivesProps {
|
||||
objectives: Objective[] | null;
|
||||
missionId?: number;
|
||||
isEditable?: boolean;
|
||||
canReveal?: boolean;
|
||||
}
|
||||
|
||||
export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
||||
function ObjectiveText({
|
||||
text,
|
||||
redacted,
|
||||
canReveal,
|
||||
className,
|
||||
}: {
|
||||
text: string | null | undefined;
|
||||
redacted: boolean;
|
||||
canReveal: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!redacted) {
|
||||
return <span className={className}>{text ?? "—"}</span>;
|
||||
}
|
||||
|
||||
const display = redactText(text) ?? "—";
|
||||
|
||||
if (!canReveal) {
|
||||
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
|
||||
{text ?? "—"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MissionObjectives({
|
||||
objectives,
|
||||
missionId,
|
||||
isEditable = false,
|
||||
canReveal = false,
|
||||
}: MissionObjectivesProps) {
|
||||
const router = useRouter();
|
||||
const [togglingIndex, setTogglingIndex] = useState<number | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||
|
||||
if (!objectives || objectives.length === 0) {
|
||||
return (
|
||||
<Item variant="outline">
|
||||
|
|
@ -37,7 +98,7 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
|||
Objectives
|
||||
</h2>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
|
|
@ -53,14 +114,29 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
|||
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||
Success / Fail Conditions
|
||||
</th>
|
||||
{isEditable && (
|
||||
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objectives.map((obj, index) => (
|
||||
<tr key={index} className="border-b border-border/50">
|
||||
<td className="px-4 py-2 font-medium">{obj.title}</td>
|
||||
<td className="px-4 py-2 font-medium">
|
||||
<ObjectiveText
|
||||
text={obj.title}
|
||||
redacted={!!obj.redacted}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word">
|
||||
{obj.description ?? "—"}
|
||||
<ObjectiveText
|
||||
text={obj.description}
|
||||
redacted={!!obj.redacted}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{obj.missionCritical ? (
|
||||
|
|
@ -70,13 +146,62 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
|
|||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{obj.successOrFailConditions ?? "—"}
|
||||
<ObjectiveText
|
||||
text={obj.successOrFailConditions}
|
||||
redacted={!!obj.redacted}
|
||||
canReveal={canReveal}
|
||||
/>
|
||||
</td>
|
||||
{isEditable && (
|
||||
<td className="px-4 py-2 text-center whitespace-nowrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={togglingIndex !== null}
|
||||
onClick={async () => {
|
||||
if (!missionId || togglingIndex !== null) return;
|
||||
setToggleError(null);
|
||||
setTogglingIndex(index);
|
||||
try {
|
||||
const res = await toggleObjectiveRedacted({
|
||||
missionId,
|
||||
objectiveIndex: index,
|
||||
redacted: !obj.redacted,
|
||||
});
|
||||
if (!res.success) {
|
||||
setToggleError(res.error ?? "Failed to update objective.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
setToggleError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setTogglingIndex(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{togglingIndex === index ? (
|
||||
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
|
||||
) : obj.redacted ? (
|
||||
<>
|
||||
<EyeIcon className="size-3.5 mr-1" />
|
||||
Show
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOffIcon className="size-3.5 mr-1" />
|
||||
Hide
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{toggleError && <p className="text-sm text-destructive">{toggleError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,25 +401,25 @@ export function RosterView({
|
|||
<RosterDisplayContext.Provider value={leadOrActual}>
|
||||
<Tabs defaultValue="org">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="org">
|
||||
<TabsTrigger value="org" className="gap-1.5">
|
||||
<NetworkIcon className="size-3" />
|
||||
Org Chart
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="assignment">
|
||||
<TabsTrigger value="assignment" className="gap-1.5">
|
||||
<Building2Icon className="size-3" />
|
||||
By Assignment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rank">
|
||||
<TabsTrigger value="rank" className="gap-1.5">
|
||||
<ShieldIcon className="size-3" />
|
||||
By Rank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="alpha">
|
||||
<TabsTrigger value="alpha" className="gap-1.5">
|
||||
<ListIcon className="size-3" />
|
||||
A–Z
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="org">
|
||||
<TabsContent value="org" className="overflow-x-scroll">
|
||||
<OrgChartView assignments={assignments} members={members} />
|
||||
</TabsContent>
|
||||
<TabsContent value="assignment">
|
||||
|
|
|
|||
28
src/lib/arma-bridge/json.ts
Normal file
28
src/lib/arma-bridge/json.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* JSON value accepted by the bridge collections' `json` fields.
|
||||
* Note: Payload 3's json field validation rejects plain string values, so
|
||||
* strings are deliberately absent from this union — they normalize to the
|
||||
* fallback instead of failing the write.
|
||||
*/
|
||||
export type BridgeJsonValue =
|
||||
| { [k: string]: unknown }
|
||||
| unknown[]
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Narrows an untrusted `unknown` value from a request body to a value that
|
||||
* Payload's json field validation accepts. Anything else (undefined,
|
||||
* strings, functions, symbols) falls back to the provided default.
|
||||
*/
|
||||
export function asJson(value: unknown, fallback: BridgeJsonValue = {}): BridgeJsonValue {
|
||||
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
// Runtime-verified object or array — safe to store as JSON.
|
||||
return value as BridgeJsonValue;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
37
src/lib/arma-bridge/server.ts
Normal file
37
src/lib/arma-bridge/server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { GameServer } from "@/payload-types";
|
||||
import { authenticateArmaBridge } from "./auth";
|
||||
|
||||
/**
|
||||
* Shared guard for bridge routes that operate on behalf of a known game
|
||||
* server: verifies the bridge API key + server id headers, then resolves the
|
||||
* matching `game-servers` doc.
|
||||
*
|
||||
* Returns the Payload instance and server doc on success, or a NextResponse
|
||||
* (503/401/400/409) the route should return verbatim.
|
||||
*/
|
||||
export async function requireArmaServer(
|
||||
req: NextRequest,
|
||||
): Promise<
|
||||
| { payload: Awaited<ReturnType<typeof getPayload>>; server: GameServer }
|
||||
| NextResponse
|
||||
> {
|
||||
const serverId = authenticateArmaBridge(req);
|
||||
if (serverId instanceof NextResponse) return serverId;
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
const servers = await payload.find({
|
||||
collection: "game-servers",
|
||||
where: { serverId: { equals: serverId } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const server = servers.docs[0];
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 });
|
||||
}
|
||||
|
||||
return { payload, server };
|
||||
}
|
||||
96
src/lib/intelligence/missionLifecycle.ts
Normal file
96
src/lib/intelligence/missionLifecycle.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { Where } from "payload";
|
||||
import type { Mission } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
/**
|
||||
* Statuses that count as "committed to run" and are auto-completed once the
|
||||
* calendar day of their scheduled start has fully passed. Draft statuses
|
||||
* (Concept/Planning/Ready) are left untouched — a mission that never ran
|
||||
* should not silently become Completed — and terminal statuses
|
||||
* (Completed/Cancelled) are never re-processed.
|
||||
*/
|
||||
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
|
||||
|
||||
export interface MissionAutoCompleteSummary {
|
||||
completed: number;
|
||||
missions: { id: number; name: string; codeName: string | null }[];
|
||||
}
|
||||
|
||||
export interface MissionAutoCompleteOptions {
|
||||
/** Restrict the sweep to specific mission ids (lets tests stay hermetic). */
|
||||
missionIds?: number[];
|
||||
}
|
||||
|
||||
/** Start of `now`'s calendar day in the server's local timezone. */
|
||||
export function startOfToday(now: Date = new Date()): Date {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return start;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the calendar day of `startDateTime` (server-local) has fully
|
||||
* ended — i.e. from the start of the day AFTER the scheduled date. A mission
|
||||
* scheduled for today never counts, even after its start time has passed.
|
||||
* Unparseable dates fall closed so malformed data is never auto-completed.
|
||||
*/
|
||||
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean {
|
||||
const start = new Date(startDateTime);
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start.getTime() < startOfToday(now).getTime();
|
||||
}
|
||||
|
||||
export async function autoCompleteMissions(
|
||||
payload: PayloadType,
|
||||
now: Date = new Date(),
|
||||
options: MissionAutoCompleteOptions = {},
|
||||
): Promise<MissionAutoCompleteSummary> {
|
||||
const cutoff = startOfToday(now).toISOString();
|
||||
const conditions: Where[] = [
|
||||
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
|
||||
{ "classification.startDateTime": { less_than: cutoff } },
|
||||
];
|
||||
if (options.missionIds) {
|
||||
conditions.push({ id: { in: options.missionIds } });
|
||||
}
|
||||
|
||||
const due = (await payload.find({
|
||||
collection: "missions",
|
||||
where: { and: conditions },
|
||||
sort: "classification.startDateTime",
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Mission[] };
|
||||
|
||||
const completed: MissionAutoCompleteSummary["missions"] = [];
|
||||
for (const mission of due.docs) {
|
||||
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { ownershipAndStatus: { status: "Completed" } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MissionAutoComplete,
|
||||
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed — the scheduled day has passed.`,
|
||||
targetCollection: "missions",
|
||||
targetId: mission.id,
|
||||
data: { previousStatus },
|
||||
});
|
||||
completed.push({ id: mission.id, name: mission.name, codeName: mission.codeName ?? null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(
|
||||
`[Mission Tick] Failed to auto-complete mission ${mission.id} (${mission.name}): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { completed: completed.length, missions: completed };
|
||||
}
|
||||
6
src/lib/redaction.ts
Normal file
6
src/lib/redaction.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const BLOCK = "█";
|
||||
|
||||
export function redactText(text: string | null | undefined): string | null {
|
||||
if (!text) return null;
|
||||
return text.replace(/\S/g, BLOCK);
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ export type SupportedTimezones =
|
|||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
|
|
@ -112,6 +113,7 @@ export interface Config {
|
|||
'arma-sync-events': ArmaSyncEvent;
|
||||
'arma-commands': ArmaCommand;
|
||||
'plugin-ai-instructions': PluginAiInstruction;
|
||||
'payload-mcp-api-keys': PayloadMcpApiKey;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
|
|
@ -171,6 +173,7 @@ export interface Config {
|
|||
'arma-sync-events': ArmaSyncEventsSelect<false> | ArmaSyncEventsSelect<true>;
|
||||
'arma-commands': ArmaCommandsSelect<false> | ArmaCommandsSelect<true>;
|
||||
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
|
||||
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
|
|
@ -192,7 +195,7 @@ export interface Config {
|
|||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
user: User | PayloadMcpApiKey;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
|
|
@ -214,6 +217,24 @@ export interface UserAuthOperations {
|
|||
username: string;
|
||||
};
|
||||
}
|
||||
export interface PayloadMcpApiKeyAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
|
|
@ -1903,6 +1924,10 @@ export interface Mission {
|
|||
*/
|
||||
missionCritical?: boolean | null;
|
||||
successOrFailConditions?: string | null;
|
||||
/**
|
||||
* If true, this objective will be hidden from players until revealed.
|
||||
*/
|
||||
redacted?: boolean | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
|
|
@ -2872,6 +2897,33 @@ export interface PluginAiInstruction {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* API keys control which collections, resources, tools, and prompts MCP clients can access
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-mcp-api-keys".
|
||||
*/
|
||||
export interface PayloadMcpApiKey {
|
||||
id: number;
|
||||
/**
|
||||
* The user that the API key is associated with.
|
||||
*/
|
||||
user: number | User;
|
||||
/**
|
||||
* A useful label for the API key.
|
||||
*/
|
||||
label?: string | null;
|
||||
/**
|
||||
* The purpose of the API key.
|
||||
*/
|
||||
description?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
enableAPIKey?: boolean | null;
|
||||
apiKey?: string | null;
|
||||
apiKeyIndex?: string | null;
|
||||
collection: 'payload-mcp-api-keys';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
|
|
@ -3075,12 +3127,21 @@ export interface PayloadLockedDocument {
|
|||
| ({
|
||||
relationTo: 'plugin-ai-instructions';
|
||||
value: number | PluginAiInstruction;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'payload-mcp-api-keys';
|
||||
value: number | PayloadMcpApiKey;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
user:
|
||||
| {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
}
|
||||
| {
|
||||
relationTo: 'payload-mcp-api-keys';
|
||||
value: number | PayloadMcpApiKey;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -3090,10 +3151,15 @@ export interface PayloadLockedDocument {
|
|||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
user:
|
||||
| {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
}
|
||||
| {
|
||||
relationTo: 'payload-mcp-api-keys';
|
||||
value: number | PayloadMcpApiKey;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
|
|
@ -3514,6 +3580,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
description?: T;
|
||||
missionCritical?: T;
|
||||
successOrFailConditions?: T;
|
||||
redacted?: T;
|
||||
id?: T;
|
||||
};
|
||||
civilianPresence?: T;
|
||||
|
|
@ -4364,6 +4431,20 @@ export interface PluginAiInstructionsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-mcp-api-keys_select".
|
||||
*/
|
||||
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
label?: T;
|
||||
description?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
enableAPIKey?: T;
|
||||
apiKey?: T;
|
||||
apiKeyIndex?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ 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";
|
||||
|
||||
|
|
@ -104,11 +105,7 @@ function plainTextToLexicalJson(text: string): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
async function generatePlainText(
|
||||
prompt: string,
|
||||
system: string,
|
||||
model: string,
|
||||
): Promise<string> {
|
||||
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",
|
||||
|
|
@ -280,6 +277,10 @@ export default buildConfig({
|
|||
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",
|
||||
|
|
@ -288,6 +289,7 @@ export default buildConfig({
|
|||
plugins: [
|
||||
// payloadCloudPlugin(),
|
||||
// storage-adapter-placeholder
|
||||
mcpPlugin({}),
|
||||
payloadAiPlugin({
|
||||
collections: {
|
||||
[GameHardResources.slug]: true,
|
||||
|
|
|
|||
|
|
@ -2,70 +2,76 @@ import type { SanitizedConfig } from "payload";
|
|||
import { getPayload } from "payload";
|
||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import { processShipmentTick } from "./processShipmentTick";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("game-tick", payload.logger);
|
||||
|
||||
payload.logger.info("[Game Tick] Starting game tick...");
|
||||
try {
|
||||
log.info("[Game Tick] Starting game tick...");
|
||||
|
||||
const activeShipments = await payload.find({
|
||||
collection: "shipments",
|
||||
where: {
|
||||
status: { in: ["dispatched", "in_transit"] },
|
||||
},
|
||||
limit: 100,
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
let processedCount = 0;
|
||||
for (const shipment of activeShipments.docs) {
|
||||
try {
|
||||
await processShipmentTick(
|
||||
payload,
|
||||
shipment as unknown as Shipment & {
|
||||
transportVehicle: GameVehicle & { type: Vehicle };
|
||||
origin: GameStructure;
|
||||
destination: GameStructure;
|
||||
},
|
||||
);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Game Tick] Error processing shipment ${shipment.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GameTick,
|
||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||
data: {
|
||||
processedCount,
|
||||
totalActive: activeShipments.docs.length,
|
||||
},
|
||||
});
|
||||
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
const activeShipments = await payload.find({
|
||||
collection: "shipments",
|
||||
where: {
|
||||
status: { in: ["dispatched", "in_transit"] },
|
||||
},
|
||||
body: JSON.stringify({ processedAt: new Date().toISOString() }),
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Game Tick] Failed to notify app clients: ${message}`);
|
||||
limit: 100,
|
||||
depth: 2,
|
||||
});
|
||||
} else {
|
||||
payload.logger.warn(
|
||||
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
|
||||
);
|
||||
}
|
||||
|
||||
payload.logger.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`);
|
||||
process.exit(0);
|
||||
};
|
||||
let processedCount = 0;
|
||||
for (const shipment of activeShipments.docs) {
|
||||
try {
|
||||
await processShipmentTick(
|
||||
payload,
|
||||
shipment as unknown as Shipment & {
|
||||
transportVehicle: GameVehicle & { type: Vehicle };
|
||||
origin: GameStructure;
|
||||
destination: GameStructure;
|
||||
},
|
||||
log,
|
||||
);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
log.error(`[Game Tick] Error processing shipment ${shipment.id}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GameTick,
|
||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||
data: {
|
||||
processedCount,
|
||||
totalActive: activeShipments.docs.length,
|
||||
},
|
||||
});
|
||||
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
},
|
||||
body: JSON.stringify({ processedAt: new Date().toISOString() }),
|
||||
}).catch((error: unknown) => {
|
||||
log.error(`[Game Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
} else {
|
||||
log.warn("[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
|
||||
}
|
||||
|
||||
log.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Game Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,85 +1,162 @@
|
|||
import type { SanitizedConfig } from "payload";
|
||||
import type { Field, SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { Missions } from "@/collections/intelligence/Missions";
|
||||
import { createBinLogger } from "./lib/binFileLogger";
|
||||
|
||||
/**
|
||||
* Deletes the `id` property from every array row in `data`, walking the
|
||||
* collection's field schema (groups, tabs, rows, nested arrays).
|
||||
*
|
||||
* Payload array rows in Postgres carry their own primary keys (UUID strings).
|
||||
* Cloning a doc whose array rows still hold the source doc's row ids makes the
|
||||
* drizzle adapter insert rows whose ids already exist — unique-constraint
|
||||
* violation, surfacing as `ValidationError: The following field is invalid: id`.
|
||||
*
|
||||
* Schema-driven on purpose: a blind deep-strip of `id` keys would also walk
|
||||
* richText (Lexical) JSON blobs, which must be cloned verbatim.
|
||||
*/
|
||||
function stripArrayRowIds(data: unknown, fields: readonly Field[]): void {
|
||||
if (!data || typeof data !== "object") return;
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
for (const field of fields) {
|
||||
switch (field.type) {
|
||||
case "array": {
|
||||
const rows = record[field.name];
|
||||
if (Array.isArray(rows)) {
|
||||
for (const row of rows) {
|
||||
if (row && typeof row === "object") {
|
||||
delete (row as Record<string, unknown>).id;
|
||||
stripArrayRowIds(row, field.fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "group":
|
||||
if ("name" in field) {
|
||||
stripArrayRowIds(record[field.name], field.fields);
|
||||
} else {
|
||||
stripArrayRowIds(record, field.fields);
|
||||
}
|
||||
break;
|
||||
case "tabs":
|
||||
for (const tab of field.tabs) {
|
||||
if ("name" in tab) {
|
||||
stripArrayRowIds(record[tab.name], tab.fields);
|
||||
} else {
|
||||
stripArrayRowIds(record, tab.fields);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "row":
|
||||
stripArrayRowIds(record, field.fields);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("generate-mission", payload.logger);
|
||||
|
||||
payload.logger.info("[Generate Mission] Starting next mission template generation...");
|
||||
try {
|
||||
log.info("[Generate Mission] Starting next mission template generation...");
|
||||
|
||||
const latest = await payload.find({
|
||||
collection: "missions",
|
||||
where: { operationType: { equals: "main" } },
|
||||
sort: "-createdAt",
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
const latest = await payload.find({
|
||||
collection: "missions",
|
||||
where: { operationType: { equals: "main" } },
|
||||
sort: "-createdAt",
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const source = latest.docs[0];
|
||||
if (!source) {
|
||||
payload.logger.info(
|
||||
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
|
||||
const source = latest.docs[0];
|
||||
if (!source) {
|
||||
log.info(
|
||||
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Same logic as the Missions collection startDateTime default: the upcoming
|
||||
// Saturday at 20:00, always at least a week out.
|
||||
const today = new Date();
|
||||
const day = today.getDay();
|
||||
const diff = (6 - day) % 7;
|
||||
const nextSaturday = new Date(today);
|
||||
nextSaturday.setDate(today.getDate() + diff + 7);
|
||||
nextSaturday.setHours(20, 0, 0, 0);
|
||||
|
||||
const dateStamp = [
|
||||
nextSaturday.getFullYear(),
|
||||
String(nextSaturday.getMonth() + 1).padStart(2, "0"),
|
||||
String(nextSaturday.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
|
||||
// The source may itself be a previously generated clone whose codeName
|
||||
// already ends in a datestamp — strip it so reruns derive a stable
|
||||
// codeName (same target Saturday → same codeName → idempotency check
|
||||
// fires) instead of stacking a new suffix on every run.
|
||||
const baseCodeName = source.codeName.replace(/-\d{8}$/, "");
|
||||
const codeName = `${baseCodeName}-${dateStamp}`;
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "missions",
|
||||
where: { codeName: { equals: codeName } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
log.info(
|
||||
`[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const {
|
||||
id: _sourceId,
|
||||
createdAt: _sourceCreatedAt,
|
||||
updatedAt: _sourceUpdatedAt,
|
||||
...template
|
||||
} = source;
|
||||
|
||||
stripArrayRowIds(template, Missions.fields);
|
||||
|
||||
const mission = await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
...template,
|
||||
codeName,
|
||||
// Bot-managed per-mission state must never be inherited: the clone
|
||||
// would otherwise edit the source mission's attendance embed.
|
||||
discordMessageId: null,
|
||||
discordAttendanceHash: null,
|
||||
discordAttendanceSentAt: null,
|
||||
classification: {
|
||||
...template.classification,
|
||||
startDateTime: nextSaturday.toISOString(),
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
...template.ownershipAndStatus,
|
||||
status: "Concept",
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
log.info(
|
||||
`[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Same logic as the Missions collection startDateTime default: the upcoming
|
||||
// Saturday at 20:00, always at least a week out.
|
||||
const today = new Date();
|
||||
const day = today.getDay();
|
||||
const diff = (6 - day) % 7;
|
||||
const nextSaturday = new Date(today);
|
||||
nextSaturday.setDate(today.getDate() + diff + 7);
|
||||
nextSaturday.setHours(20, 0, 0, 0);
|
||||
|
||||
const dateStamp = [
|
||||
nextSaturday.getFullYear(),
|
||||
String(nextSaturday.getMonth() + 1).padStart(2, "0"),
|
||||
String(nextSaturday.getDate()).padStart(2, "0"),
|
||||
].join("");
|
||||
|
||||
const codeName = `${source.codeName}-${dateStamp}`;
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "missions",
|
||||
where: { codeName: { equals: codeName } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
payload.logger.info(
|
||||
`[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`,
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
id: _sourceId,
|
||||
createdAt: _sourceCreatedAt,
|
||||
updatedAt: _sourceUpdatedAt,
|
||||
...template
|
||||
} = source;
|
||||
|
||||
const mission = await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
...template,
|
||||
codeName,
|
||||
classification: {
|
||||
...template.classification,
|
||||
startDateTime: nextSaturday.toISOString(),
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
...template.ownershipAndStatus,
|
||||
status: "Concept",
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
payload.logger.info(
|
||||
`[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`,
|
||||
);
|
||||
process.exit(0);
|
||||
};
|
||||
|
|
|
|||
77
src/scripts/lib/binFileLogger.ts
Normal file
77
src/scripts/lib/binFileLogger.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* File logging for Payload bin scripts (game-tick, market-tick, mission-tick,
|
||||
* generate-mission).
|
||||
*
|
||||
* Each log call is appended SYNCHRONOUSLY to a daily file under
|
||||
* `logs/<scriptKey>/<YYYY-MM-DD>.log` (UTC) and also forwarded to the console
|
||||
* logger (payload.logger), so console output is unchanged. Sync writes are
|
||||
* deliberate: bin scripts call `process.exit(0)` immediately after their last
|
||||
* log line, which would truncate anything still buffered in an async stream
|
||||
* (Payload uses a sync logger for bins for the same reason).
|
||||
*
|
||||
* File logging must never break the tick: all fs errors are swallowed and the
|
||||
* message still goes to console.
|
||||
*
|
||||
* Override the directory with `BIN_LOG_DIR` (e.g. point it at a persistent
|
||||
* volume in container deployments — by default it is `<cwd>/logs`, which is
|
||||
* ephemeral inside Docker).
|
||||
*/
|
||||
|
||||
/** Minimal structural type satisfied by `payload.logger` (pino). */
|
||||
export interface ConsoleLogger {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface BinLogger {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
}
|
||||
|
||||
type Level = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
function resolveLogDir(scriptKey: string): string {
|
||||
const base = process.env.BIN_LOG_DIR ?? path.join(process.cwd(), "logs");
|
||||
return path.join(base, scriptKey);
|
||||
}
|
||||
|
||||
function logFilePath(logDir: string, now: Date): string {
|
||||
return path.join(logDir, `${now.toISOString().slice(0, 10)}.log`);
|
||||
}
|
||||
|
||||
function writeLogLine(logDir: string, level: Level, msg: string): void {
|
||||
try {
|
||||
const now = new Date();
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.appendFileSync(logFilePath(logDir, now), `${now.toISOString()} [${level}] ${msg}\n`);
|
||||
} catch {
|
||||
// File logging is best-effort; the console copy below still goes out.
|
||||
}
|
||||
}
|
||||
|
||||
export function createBinLogger(scriptKey: string, consoleLogger: ConsoleLogger): BinLogger {
|
||||
const logDir = resolveLogDir(scriptKey);
|
||||
|
||||
const tee =
|
||||
(level: Level, consoleFn: (msg: string) => void): ((msg: string) => void) =>
|
||||
(msg: string) => {
|
||||
writeLogLine(logDir, level, msg);
|
||||
consoleFn(msg);
|
||||
};
|
||||
|
||||
return {
|
||||
info: tee("INFO", (msg) => consoleLogger.info(msg)),
|
||||
warn: tee("WARN", (msg) => consoleLogger.warn(msg)),
|
||||
error: tee("ERROR", (msg) => consoleLogger.error(msg)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Uniform message extraction for caught unknowns (matches the actions.ts convention). */
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
}
|
||||
|
|
@ -8,162 +8,166 @@ import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
|
|||
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
|
||||
import { resolveAssetId } from "@/lib/locker";
|
||||
import { getMainCurrencyId } from "@/lib/banking";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("market-tick", payload.logger);
|
||||
|
||||
payload.logger.info("[Market Tick] Starting market tick...");
|
||||
try {
|
||||
log.info("[Market Tick] Starting market tick...");
|
||||
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// 1. Expire overdue listings (returns stock to sellers for user listings).
|
||||
const expiring = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: {
|
||||
and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }],
|
||||
},
|
||||
limit: 200,
|
||||
depth: 1,
|
||||
sort: "expiresAt",
|
||||
});
|
||||
|
||||
let expiredCount = 0;
|
||||
let expiredUserCount = 0;
|
||||
for (const listing of expiring.docs) {
|
||||
const sellerId =
|
||||
typeof listing.seller === "object" && listing.seller
|
||||
? (listing.seller as { id: number }).id
|
||||
: (listing.seller as number | null);
|
||||
const isUserListing = sellerId != null && !listing.isAutoGenerated;
|
||||
try {
|
||||
await expireListing(payload, listing as unknown as MarketListing);
|
||||
expiredCount++;
|
||||
if (isUserListing) expiredUserCount++;
|
||||
const assetName =
|
||||
typeof listing.asset === "object" && listing.asset
|
||||
? listing.asset.name
|
||||
: `asset #${resolveAssetId(listing.asset)}`;
|
||||
await expireNegotiationsForListing(payload, listing.id, assetName, {
|
||||
reason: "expired before you could close the deal",
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MarketListingExpire,
|
||||
message: `Listing for ${listing.quantity}× ${assetName} expired${
|
||||
isUserListing ? " — stock returned to seller" : ""
|
||||
}`,
|
||||
actor: sellerId ?? undefined,
|
||||
targetCollection: "market-listings",
|
||||
targetId: listing.id,
|
||||
data: {
|
||||
assetId: listing.asset,
|
||||
assetName,
|
||||
quantity: listing.quantity,
|
||||
autoGenerated: listing.isAutoGenerated,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Market Tick] Error expiring listing ${listing.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Top up auto-generated entries for tradeable live assets with no active listing.
|
||||
const active = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: { status: { equals: "active" } },
|
||||
limit: 2000,
|
||||
depth: 0,
|
||||
});
|
||||
const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset)));
|
||||
|
||||
const assets = await payload.find({
|
||||
collection: "assets",
|
||||
where: {
|
||||
and: [{ tradeable: { equals: true } }, { isLive: { equals: true } }],
|
||||
},
|
||||
limit: 2000,
|
||||
depth: 0,
|
||||
sort: "name",
|
||||
});
|
||||
|
||||
const currencyId = await getMainCurrencyId(payload);
|
||||
let generatedCount = 0;
|
||||
let generatedNpcCount = 0;
|
||||
for (const asset of assets.docs as unknown as Asset[]) {
|
||||
if (activeAssetIds.has(asset.id)) continue;
|
||||
const basePrice = autoPrice(asset, "buy");
|
||||
if (basePrice == null) continue;
|
||||
|
||||
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
|
||||
// fall back to generating a placeholder NPC when none sell this good.
|
||||
const npc = await resolveVendorNpc(payload, asset.id);
|
||||
if (npc.isGenerated === true) generatedNpcCount++;
|
||||
const price = applyNpcPriceModifier(basePrice, npc);
|
||||
if (price <= 0) continue;
|
||||
|
||||
const quantity = autoQuantity(asset);
|
||||
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
|
||||
const minPrice = Math.max(1, Math.round(price * 0.6));
|
||||
try {
|
||||
await payload.create({
|
||||
collection: "market-listings",
|
||||
data: {
|
||||
asset: asset.id,
|
||||
seller: null,
|
||||
npc: npc.id,
|
||||
quantity,
|
||||
price,
|
||||
desiredPrice: price,
|
||||
minPrice,
|
||||
currency: currencyId ?? null,
|
||||
status: "active",
|
||||
isAutoGenerated: true,
|
||||
listedAt: nowIso,
|
||||
expiresAt,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
generatedCount++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(
|
||||
`[Market Tick] Error generating listing for asset ${asset.id}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MarketAutoGenerate,
|
||||
message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`,
|
||||
data: {
|
||||
expiredCount,
|
||||
generatedCount,
|
||||
generatedNpcCount,
|
||||
activeListings: active.docs.length,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Notify connected clients so the market refreshes.
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
// 1. Expire overdue listings (returns stock to sellers for user listings).
|
||||
const expiring = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: {
|
||||
and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }],
|
||||
},
|
||||
body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Market Tick] Failed to notify app clients: ${message}`);
|
||||
limit: 200,
|
||||
depth: 1,
|
||||
sort: "expiresAt",
|
||||
});
|
||||
}
|
||||
|
||||
payload.logger.info(
|
||||
`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`,
|
||||
);
|
||||
process.exit(0);
|
||||
let expiredCount = 0;
|
||||
let expiredUserCount = 0;
|
||||
for (const listing of expiring.docs) {
|
||||
const sellerId =
|
||||
typeof listing.seller === "object" && listing.seller
|
||||
? (listing.seller as { id: number }).id
|
||||
: (listing.seller as number | null);
|
||||
const isUserListing = sellerId != null && !listing.isAutoGenerated;
|
||||
try {
|
||||
await expireListing(payload, listing as unknown as MarketListing);
|
||||
expiredCount++;
|
||||
if (isUserListing) expiredUserCount++;
|
||||
const assetName =
|
||||
typeof listing.asset === "object" && listing.asset
|
||||
? listing.asset.name
|
||||
: `asset #${resolveAssetId(listing.asset)}`;
|
||||
await expireNegotiationsForListing(payload, listing.id, assetName, {
|
||||
reason: "expired before you could close the deal",
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MarketListingExpire,
|
||||
message: `Listing for ${listing.quantity}× ${assetName} expired${
|
||||
isUserListing ? " — stock returned to seller" : ""
|
||||
}`,
|
||||
actor: sellerId ?? undefined,
|
||||
targetCollection: "market-listings",
|
||||
targetId: listing.id,
|
||||
data: {
|
||||
assetId: listing.asset,
|
||||
assetName,
|
||||
quantity: listing.quantity,
|
||||
autoGenerated: listing.isAutoGenerated,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`[Market Tick] Error expiring listing ${listing.id}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Top up auto-generated entries for tradeable live assets with no active listing.
|
||||
const active = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: { status: { equals: "active" } },
|
||||
limit: 2000,
|
||||
depth: 0,
|
||||
});
|
||||
const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset)));
|
||||
|
||||
const assets = await payload.find({
|
||||
collection: "assets",
|
||||
where: {
|
||||
and: [{ tradeable: { equals: true } }, { isLive: { equals: true } }],
|
||||
},
|
||||
limit: 2000,
|
||||
depth: 0,
|
||||
sort: "name",
|
||||
});
|
||||
|
||||
const currencyId = await getMainCurrencyId(payload);
|
||||
let generatedCount = 0;
|
||||
let generatedNpcCount = 0;
|
||||
for (const asset of assets.docs as unknown as Asset[]) {
|
||||
if (activeAssetIds.has(asset.id)) continue;
|
||||
const basePrice = autoPrice(asset, "buy");
|
||||
if (basePrice == null) continue;
|
||||
|
||||
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
|
||||
// fall back to generating a placeholder NPC when none sell this good.
|
||||
const npc = await resolveVendorNpc(payload, asset.id);
|
||||
if (npc.isGenerated === true) generatedNpcCount++;
|
||||
const price = applyNpcPriceModifier(basePrice, npc);
|
||||
if (price <= 0) continue;
|
||||
|
||||
const quantity = autoQuantity(asset);
|
||||
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
|
||||
const minPrice = Math.max(1, Math.round(price * 0.6));
|
||||
try {
|
||||
await payload.create({
|
||||
collection: "market-listings",
|
||||
data: {
|
||||
asset: asset.id,
|
||||
seller: null,
|
||||
npc: npc.id,
|
||||
quantity,
|
||||
price,
|
||||
desiredPrice: price,
|
||||
minPrice,
|
||||
currency: currencyId ?? null,
|
||||
status: "active",
|
||||
isAutoGenerated: true,
|
||||
listedAt: nowIso,
|
||||
expiresAt,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
generatedCount++;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Market Tick] Error generating listing for asset ${asset.id}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MarketAutoGenerate,
|
||||
message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`,
|
||||
data: {
|
||||
expiredCount,
|
||||
generatedCount,
|
||||
generatedNpcCount,
|
||||
activeListings: active.docs.length,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Notify connected clients so the market refreshes.
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
},
|
||||
body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
log.error(`[Market Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Market Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
41
src/scripts/missionTick.ts
Normal file
41
src/scripts/missionTick.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("mission-tick", payload.logger);
|
||||
|
||||
try {
|
||||
log.info("[Mission Tick] Starting mission tick...");
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const result = await autoCompleteMissions(payload);
|
||||
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
},
|
||||
body: JSON.stringify({ processedAt: nowIso, source: "mission-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
log.error(`[Mission Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
} else {
|
||||
log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
|
||||
}
|
||||
|
||||
log.info(`[Mission Tick] Complete. Auto-completed ${result.completed} mission(s).`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Mission Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import { emitGameEvent } from "@/utils/event-log/emit";
|
|||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
||||
|
||||
type ShipmentDoc = Shipment & {
|
||||
transportVehicle: GameVehicle & { type: Vehicle };
|
||||
|
|
@ -11,7 +12,11 @@ type ShipmentDoc = Shipment & {
|
|||
destination: GameStructure;
|
||||
};
|
||||
|
||||
export async function processShipmentTick(payload: Payload, shipment: ShipmentDoc): Promise<void> {
|
||||
export async function processShipmentTick(
|
||||
payload: Payload,
|
||||
shipment: ShipmentDoc,
|
||||
log: Pick<BinLogger, "error">,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const vehicle = shipment.transportVehicle;
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ export async function processShipmentTick(payload: Payload, shipment: ShipmentDo
|
|||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||
|
||||
if (now >= estimatedArrival) {
|
||||
await completeShipment(payload, shipment, newFuelConsumed);
|
||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +93,7 @@ async function completeShipment(
|
|||
payload: Payload,
|
||||
shipment: ShipmentDoc,
|
||||
finalFuelConsumed: number,
|
||||
log: Pick<BinLogger, "error">,
|
||||
): Promise<void> {
|
||||
const vehicle = shipment.transportVehicle;
|
||||
const destinationId =
|
||||
|
|
@ -138,7 +144,7 @@ async function completeShipment(
|
|||
amount: cargoEntry.amount,
|
||||
resourceName: resName,
|
||||
});
|
||||
console.error(
|
||||
log.error(
|
||||
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
|
||||
);
|
||||
continue;
|
||||
|
|
@ -169,7 +175,7 @@ async function completeShipment(
|
|||
data: { storedResources: existingResources },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to transfer cargo item ${itemId}:`, error);
|
||||
log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
43
src/tools/cli/_library.ts
Normal file
43
src/tools/cli/_library.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export class CliFlow {
|
||||
protected moduleName: string;
|
||||
|
||||
constructor(moduleName: string) {
|
||||
this.moduleName = moduleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* showHeader
|
||||
*/
|
||||
public showHeader(title: string, subtitle?: string) {
|
||||
const LENGTH = Math.max(title.length, subtitle?.length ?? 0) + 5;
|
||||
|
||||
// Chars: ╔═╗║╚═╝╬
|
||||
|
||||
// Draw the top border
|
||||
let topStr = "╔";
|
||||
for (let i = 0; i < LENGTH; i++) {
|
||||
topStr += "═";
|
||||
}
|
||||
topStr += "╗";
|
||||
console.log(topStr);
|
||||
|
||||
// Draw the title
|
||||
console.log(`║ \x1B[33;1m${title}\x1B[0m ${"║".padStart(LENGTH - title.length - 1, " ")}`);
|
||||
|
||||
// Draw the subtitle
|
||||
subtitle &&
|
||||
console.log(`║ \x1B[2m${subtitle}\x1B[0m ${"║".padStart(LENGTH - subtitle.length - 1, " ")}`);
|
||||
|
||||
// Draw the bottom border
|
||||
let btmStr = "╚";
|
||||
for (let i = 0; i < LENGTH; i++) {
|
||||
btmStr += "═";
|
||||
}
|
||||
btmStr += "╝";
|
||||
console.log(btmStr + "\n");
|
||||
}
|
||||
|
||||
log(...msg: string[]) {
|
||||
console.log(`\t\x1B[33m[LOG]\x1B[0m ${msg.join(" ")}`);
|
||||
}
|
||||
}
|
||||
7
src/tools/cli/reset-password.ts
Normal file
7
src/tools/cli/reset-password.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { CliFlow } from "./_library";
|
||||
|
||||
const prompt = require("prompt");
|
||||
|
||||
const flow = new CliFlow("reset-password");
|
||||
|
||||
flow.showHeader("Test", "Long subtitle test");
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { User } from "@/payload-types";
|
||||
import { PayloadMcpApiKey, User } from "@/payload-types";
|
||||
|
||||
export default function hasRoles(roles: NonNullable<User["roles"]>, user: User | null | undefined) {
|
||||
if (!user) return false;
|
||||
export default function hasRoles(
|
||||
roles: NonNullable<User["roles"]>,
|
||||
user: User | PayloadMcpApiKey | null | undefined,
|
||||
) {
|
||||
if (!user || user.collection !== "users") return false;
|
||||
|
||||
return roles.some((role) => user.roles?.some((urole) => urole === role || urole === "developer"));
|
||||
}
|
||||
|
|
|
|||
0
src/utils/access-control/isAuthor.ts
Normal file
0
src/utils/access-control/isAuthor.ts
Normal file
5
src/utils/access-control/isPayloadUser.ts
Normal file
5
src/utils/access-control/isPayloadUser.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { PayloadMcpApiKey, User } from "@/payload-types";
|
||||
|
||||
export function isPayloadUser(user: User | PayloadMcpApiKey | null | undefined): user is User {
|
||||
return !!user && user.collection === "users";
|
||||
}
|
||||
|
|
@ -85,6 +85,10 @@ export const EventTypes = {
|
|||
// Attendance
|
||||
MissionAttendanceChange: "mission:attendance-change",
|
||||
|
||||
// Missions
|
||||
MissionAutoComplete: "mission:auto-complete",
|
||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||
|
||||
// Helpdesk
|
||||
TicketCreate: "ticket:create",
|
||||
TicketReply: "ticket:reply",
|
||||
|
|
|
|||
208
tests/int/mission-lifecycle.int.spec.ts
Normal file
208
tests/int/mission-lifecycle.int.spec.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
||||
import {
|
||||
autoCompleteMissions,
|
||||
isPastScheduledDay,
|
||||
startOfToday,
|
||||
} from "@/lib/intelligence/missionLifecycle";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `mlc-${Date.now().toString(36)}`;
|
||||
|
||||
describe("Mission auto-complete", () => {
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let authorId: number;
|
||||
const missionIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
mapId = map.id;
|
||||
|
||||
const campaign = (await payload.create({
|
||||
collection: "campaigns",
|
||||
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Campaign;
|
||||
campaignId = campaign.id;
|
||||
|
||||
const author = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-author`,
|
||||
discordUsername: `${RUN}-author`,
|
||||
displayName: "AUTHOR",
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
authorId = author.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of missionIds) {
|
||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload
|
||||
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
||||
// User deletion trips FK constraints unless the hook-provisioned
|
||||
// personal bank account and profile are removed first.
|
||||
const accounts = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: authorId } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
const profiles = await payload
|
||||
.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: authorId } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const makeMission = async (
|
||||
label: string,
|
||||
status: Mission["ownershipAndStatus"]["status"],
|
||||
start: Date,
|
||||
): Promise<Mission> => {
|
||||
const mission = (await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} ${label}`,
|
||||
codeName: `${RUN}-${label}`,
|
||||
summary: "Auto-complete boundary test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: start.toISOString(),
|
||||
estimatedDuration: 60,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [authorId],
|
||||
status,
|
||||
visibility: "unit",
|
||||
},
|
||||
missionRoles: {
|
||||
maxPlayers: 16,
|
||||
},
|
||||
gameDetails: {
|
||||
serverDetails: {
|
||||
serverIp: "127.0.0.1",
|
||||
serverPort: 2302,
|
||||
},
|
||||
},
|
||||
briefing: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Mission;
|
||||
missionIds.push(mission.id);
|
||||
return mission;
|
||||
};
|
||||
|
||||
const fetchMission = (id: number): Promise<Mission> =>
|
||||
payload.findByID({
|
||||
collection: "missions",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}) as unknown as Promise<Mission>;
|
||||
|
||||
it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
const at = (dayOffset: number, hour: number): Date => {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + dayOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20));
|
||||
const dueActive = await makeMission("due-active", "Active", at(-3, 20));
|
||||
const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23));
|
||||
const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8));
|
||||
const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13));
|
||||
const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20));
|
||||
const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20));
|
||||
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
|
||||
expect(result.missions.map((m) => m.id).sort()).toEqual(
|
||||
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(),
|
||||
);
|
||||
|
||||
expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed");
|
||||
expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||
expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready");
|
||||
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
|
||||
|
||||
// The partial group update must not wipe sibling fields.
|
||||
const updated = await fetchMission(dueScheduled.id);
|
||||
expect(updated.ownershipAndStatus.visibility).toBe("unit");
|
||||
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
|
||||
? updated.ownershipAndStatus.authors
|
||||
: [updated.ownershipAndStatus.authors];
|
||||
expect(authorRefs).toContain(authorId);
|
||||
});
|
||||
|
||||
it("is idempotent — a second sweep finds nothing left to complete", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
expect(result.completed).toBe(0);
|
||||
expect(result.missions).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies day boundaries deterministically", () => {
|
||||
const now = new Date(2026, 7, 24, 12, 0, 0);
|
||||
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
|
||||
expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true);
|
||||
expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false);
|
||||
expect(isPastScheduledDay("not-a-date", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue