1
0
Fork 0

Compare commits

..

No commits in common. "d6b2ffa5d20845aa936e4754e11c05b9beafbcc2" and "98f2b0447ab57bcf59ff78a0a888130227cab450" have entirely different histories.

49 changed files with 1044 additions and 1960 deletions

View file

@ -22,13 +22,6 @@ 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=
@ -57,10 +50,4 @@ APP_PORT=3000
# DISCORD_ANNOUNCE_CHANNEL_ID=
# DISCORD_STAFF_ROLE_IDS=
# DISCORD_ATTENDANCE_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=
# DISCORD_NOTIFICATION_POLL_MS=

3
.gitignore vendored
View file

@ -31,9 +31,6 @@ 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

View file

@ -147,8 +147,6 @@ 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).

View file

@ -57,13 +57,10 @@ 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)**.

972
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -24,8 +24,7 @@
"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",
"u:reset-password": "bun run src/tools/cli/reset-password.ts"
"postversion": "bun run generate:version-info && bash ./build/deploy.sh"
},
"dependencies": {
"@ai-stack/payloadcms": "^3.2.30",
@ -36,64 +35,62 @@
"@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.2.6",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-navigation-menu": "^1.2.22",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/postcss": "^4.3.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.18",
"@types/nodemailer": "^8.0.1",
"@xyflow/react": "^12.11.3",
"@xyflow/react": "^12.11.2",
"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.14.2",
"graphql": "^16.12.0",
"lucide-react": "^0.561.0",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.5",
"nodemailer": "^9.0.3",
"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.6.0",
"tailwindcss": "^4.3.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tsx": "^4.23.12"
},
"devDependencies": {
"@playwright/test": "1.57.0",
"@testing-library/react": "16.3.1",
"@types/node": "^25.9.5",
"@types/node": "^25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.5",
"drizzle-kit": "^0.31.8",
"eslint": "^9.39.2",
"eslint-config-next": "16.0.10",
"jsdom": "27.3.0",
"playwright": "1.57.0",
"playwright-core": "1.57.0",
"prettier": "^3.9.6",
"prettier": "^3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.0.1",

View file

@ -1,7 +1,6 @@
"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";
@ -24,7 +23,7 @@ async function authenticate() {
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
if (!user) {
throw new Error("Unauthorized");
}

View file

@ -1,7 +1,6 @@
"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";
@ -26,7 +25,7 @@ async function authenticate() {
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
if (!user) {
throw new Error("Unauthorized");
}

View file

@ -3,7 +3,6 @@ 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",
@ -72,27 +71,17 @@ export default async function CampaignsPage() {
return (
<div className="p-5 flex flex-col gap-6">
<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>
<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 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>
{sorted.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{campaignsWithStats.map((campaign) => (

View file

@ -17,8 +17,6 @@ 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";
@ -140,28 +138,6 @@ 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
@ -313,12 +289,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
<MissionBriefingSection briefing={mission.briefing ?? null} />
<MissionObjectives
objectives={objectives}
missionId={mission.id}
isEditable={canManage}
canReveal={canReveal}
/>
<MissionObjectives objectives={mission.missionObjectives ?? null} />
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />

View file

@ -1,94 +0,0 @@
"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 };
}
}

View file

@ -4,7 +4,6 @@ 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";
@ -51,8 +50,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payloadConfig = await config;
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const { user } = await payload.auth({ headers, canSetHeaders: false });
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = {

View file

@ -1,7 +1,6 @@
"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";
@ -40,7 +39,7 @@ async function authenticate() {
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
if (!user) {
throw new Error("Unauthorized");
}

View file

@ -1,5 +1,4 @@
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";
@ -14,11 +13,10 @@ export const metadata = {
export default async function LockerPage() {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
const { user } = 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);

View file

@ -1,7 +1,6 @@
"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";
@ -32,7 +31,7 @@ async function authenticate() {
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
if (!user) {
throw new Error("Unauthorized");
}

View file

@ -1,5 +1,4 @@
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";
@ -14,11 +13,10 @@ export const metadata = {
export default async function BankingPage() {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
const { user } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const user = isPayloadUser(authUser) ? authUser : null;
const accountsRes = await payload.find({
collection: "bank-accounts",

View file

@ -1,7 +1,6 @@
"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";
@ -38,7 +37,7 @@ async function authenticate() {
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
if (!user) {
throw new Error("Unauthorized");
}

View file

@ -72,12 +72,6 @@ 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;
@ -86,49 +80,22 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<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}
<div className="flex flex-col gap-3">
<Link
href="/logistics"
className="relative z-10 text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
className="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="relative z-10 flex items-start justify-between gap-4">
<div className="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 ${imageUrl ? "text-white/80" : "text-muted-foreground"}`}
/>
<h1
className={`text-lg font-semibold ${imageUrl ? "text-white drop-shadow-md" : ""}`}
>
{structure.name}
</h1>
<Building2Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{structure.name}</h1>
</div>
<div
className={`flex items-center gap-2 text-sm ${
imageUrl ? "text-white/70" : "text-muted-foreground"
}`}
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{structureType.name}</span>
<span>·</span>
<span>{getCategoryLabel(structureType.category)}</span>
@ -149,7 +116,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
{user && <ManageStorageButton structureId={structure.id} />}
</div>
</div>
</header>
</div>
{description && <p className="text-sm text-muted-foreground">{description}</p>}

View file

@ -1,5 +1,4 @@
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";
@ -35,8 +34,7 @@ export type RosterAssignment = {
export default async function RosterPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const currentUser = isPayloadUser(authUser) ? authUser : null;
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false });
const [profileRes, assignmentRes] = await Promise.all([
payload.find({

View file

@ -1,42 +1,23 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { asJson } from "@/lib/arma-bridge/json";
import { requireArmaServer } from "@/lib/arma-bridge/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
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 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 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: asJson(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: body.result ?? {}, error: typeof body.error === "string" ? body.error : undefined, completedAt: new Date().toISOString() }, overrideAccess: true });
return NextResponse.json({ ok: true });
}

View file

@ -1,39 +1,20 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { requireArmaServer } from "@/lib/arma-bridge/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
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 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 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 })) });
}

View file

@ -1,83 +1,30 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { asJson } from "@/lib/arma-bridge/json";
import { requireArmaServer } from "@/lib/arma-bridge/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
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 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") {
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),
});
}
// 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));
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;
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;
}
}
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++;
}
return NextResponse.json({ ok: true, accepted, duplicates, rejected });
return NextResponse.json({ ok: true, accepted, duplicates });
}

View file

@ -2,7 +2,6 @@ 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";
@ -10,36 +9,10 @@ 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 { name?: unknown; metadata?: unknown } | null;
const body = await req.json().catch(() => ({}));
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: 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,
});
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 });
return NextResponse.json({ ok: true, serverId: server.id });
}

View file

@ -1,6 +1,5 @@
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";
@ -21,7 +20,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 (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
touchPresence(channel, {
id: user.id as number,
name: user.payloadDisplayName || user.username || `Pilot #${user.id}`,
@ -33,7 +32,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 (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
removePresence(channel, user.id as number);
return NextResponse.json({ ok: true });
}

View file

@ -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,9 +385,7 @@ 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(() => {

View file

@ -437,16 +437,6 @@ 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,
},
],
},
{

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const Technologies: CollectionConfig = {
slug: "technologies",
@ -33,9 +32,7 @@ export const Technologies: CollectionConfig = {
],
defaultValue: "in_progress",
filterOptions: ({ options, req }) => {
const roles = isPayloadUser(req.user)
? ((req.user.roles as string[] | undefined) ?? [])
: [];
const roles = (req.user?.roles as string[] | undefined) ?? [];
const canReadAll = roles.includes("admin") || roles.includes("developer");
if (canReadAll) return options;
return options.filter((option) =>

View file

@ -17,7 +17,6 @@ import {
Warehouse,
Maximize,
Minimize,
SparkleIcon,
} from "lucide-react";
import { NavCore } from "@/components/frontend/blocks/NavCore";
@ -30,7 +29,7 @@ import {
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import Image from "next/image";
import Link from "next/link";
@ -178,7 +177,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()).
@ -277,9 +276,12 @@ 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>
<SparkleIcon className="w-4 h-4" />
<span className="text-xs font-bold">{xpLevel.xp.toLocaleString()}</span>
<div className="text-xs text-muted-foreground uppercase">
{xpLevel.levelName}
</div>
<span className="text-xs font-bold">
{xpLevel.xp.toLocaleString()}
</span>
<Progress value={xpLevel.progress} className="w-20 h-1" />
</div>
) : (

View file

@ -1,79 +1,18 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CheckIcon, CrosshairIcon, EyeIcon, EyeOffIcon, Loader2Icon, XIcon } from "lucide-react";
import { CheckIcon, CrosshairIcon, 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;
}
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);
export function MissionObjectives({ objectives }: MissionObjectivesProps) {
if (!objectives || objectives.length === 0) {
return (
<Item variant="outline">
@ -98,7 +37,7 @@ export function MissionObjectives({
Objectives
</h2>
</div>
<div className="border border-border rounded-lg overflow-x-auto">
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
@ -114,29 +53,14 @@ export function MissionObjectives({
<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">
<ObjectiveText
text={obj.title}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
</td>
<td className="px-4 py-2 font-medium">{obj.title}</td>
<td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word">
<ObjectiveText
text={obj.description}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
{obj.description ?? "—"}
</td>
<td className="px-4 py-2 text-center">
{obj.missionCritical ? (
@ -146,62 +70,13 @@ export function MissionObjectives({
)}
</td>
<td className="px-4 py-2 text-muted-foreground">
<ObjectiveText
text={obj.successOrFailConditions}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
{obj.successOrFailConditions ?? "—"}
</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>
);
}

View file

@ -401,25 +401,25 @@ export function RosterView({
<RosterDisplayContext.Provider value={leadOrActual}>
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org" className="gap-1.5">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment" className="gap-1.5">
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank" className="gap-1.5">
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha" className="gap-1.5">
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<TabsContent value="org" className="overflow-x-scroll">
<TabsContent value="org">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">

View file

@ -1,28 +0,0 @@
/**
* 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;
}

View file

@ -1,37 +0,0 @@
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 };
}

View file

@ -1,96 +0,0 @@
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 };
}

View file

@ -1,6 +0,0 @@
const BLOCK = "█";
export function redactText(text: string | null | undefined): string | null {
if (!text) return null;
return text.replace(/\S/g, BLOCK);
}

View file

@ -64,7 +64,6 @@ export type SupportedTimezones =
export interface Config {
auth: {
users: UserAuthOperations;
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
};
blocks: {};
collections: {
@ -113,7 +112,6 @@ 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;
@ -173,7 +171,6 @@ 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>;
@ -195,7 +192,7 @@ export interface Config {
widgets: {
collections: CollectionsWidget;
};
user: User | PayloadMcpApiKey;
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
@ -217,24 +214,6 @@ 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".
@ -1924,10 +1903,6 @@ 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;
@ -2897,33 +2872,6 @@ 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".
@ -3127,21 +3075,12 @@ 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;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
};
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
@ -3151,15 +3090,10 @@ export interface PayloadLockedDocument {
*/
export interface PayloadPreference {
id: number;
user:
| {
relationTo: 'users';
value: number | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: number | PayloadMcpApiKey;
};
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
@ -3580,7 +3514,6 @@ export interface MissionsSelect<T extends boolean = true> {
description?: T;
missionCritical?: T;
successOrFailConditions?: T;
redacted?: T;
id?: T;
};
civilianPresence?: T;
@ -4431,20 +4364,6 @@ 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".

View file

@ -56,7 +56,6 @@ 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";
@ -105,7 +104,11 @@ 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",
@ -277,10 +280,6 @@ 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",
@ -289,7 +288,6 @@ export default buildConfig({
plugins: [
// payloadCloudPlugin(),
// storage-adapter-placeholder
mcpPlugin({}),
payloadAiPlugin({
collections: {
[GameHardResources.slug]: true,

View file

@ -2,76 +2,70 @@ 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);
try {
log.info("[Game Tick] Starting game tick...");
payload.logger.info("[Game Tick] Starting game tick...");
const activeShipments = await payload.find({
collection: "shipments",
where: {
status: { in: ["dispatched", "in_transit"] },
},
limit: 100,
depth: 2,
});
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;
},
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,
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;
},
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");
);
processedCount++;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Game Tick] Error processing shipment ${shipment.id}: ${message}`);
}
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);
}
};
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) => {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Game Tick] Failed to notify app clients: ${message}`);
});
} 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);
};

View file

@ -1,162 +1,85 @@
import type { Field, SanitizedConfig } from "payload";
import type { 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);
try {
log.info("[Generate Mission] Starting next mission template generation...");
payload.logger.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) {
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}`,
const source = latest.docs[0];
if (!source) {
payload.logger.info(
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
);
process.exit(0);
} catch (error) {
log.error(
`[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(1);
}
// 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.`,
);
process.exit(0);
}
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);
};

View file

@ -1,77 +0,0 @@
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";
}

View file

@ -8,166 +8,162 @@ 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);
try {
log.info("[Market Tick] Starting market tick...");
payload.logger.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",
});
// 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) {
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)}`);
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}`);
}
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);
}
// 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,
},
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}`);
});
}
payload.logger.info(
`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`,
);
process.exit(0);
};

View file

@ -1,41 +0,0 @@
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);
}
};

View file

@ -4,7 +4,6 @@ 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 };
@ -12,11 +11,7 @@ type ShipmentDoc = Shipment & {
destination: GameStructure;
};
export async function processShipmentTick(
payload: Payload,
shipment: ShipmentDoc,
log: Pick<BinLogger, "error">,
): Promise<void> {
export async function processShipmentTick(payload: Payload, shipment: ShipmentDoc): Promise<void> {
const now = new Date();
const vehicle = shipment.transportVehicle;
@ -75,7 +70,7 @@ export async function processShipmentTick(
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
if (now >= estimatedArrival) {
await completeShipment(payload, shipment, newFuelConsumed, log);
await completeShipment(payload, shipment, newFuelConsumed);
return;
}
@ -93,7 +88,6 @@ async function completeShipment(
payload: Payload,
shipment: ShipmentDoc,
finalFuelConsumed: number,
log: Pick<BinLogger, "error">,
): Promise<void> {
const vehicle = shipment.transportVehicle;
const destinationId =
@ -144,7 +138,7 @@ async function completeShipment(
amount: cargoEntry.amount,
resourceName: resName,
});
log.error(
console.error(
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
);
continue;
@ -175,7 +169,7 @@ async function completeShipment(
data: { storedResources: existingResources },
});
} catch (error) {
log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`);
console.error(`Failed to transfer cargo item ${itemId}:`, error);
}
}

View file

@ -1,43 +0,0 @@
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(" ")}`);
}
}

View file

@ -1,7 +0,0 @@
import { CliFlow } from "./_library";
const prompt = require("prompt");
const flow = new CliFlow("reset-password");
flow.showHeader("Test", "Long subtitle test");

View file

@ -1,10 +1,7 @@
import { PayloadMcpApiKey, User } from "@/payload-types";
import { User } from "@/payload-types";
export default function hasRoles(
roles: NonNullable<User["roles"]>,
user: User | PayloadMcpApiKey | null | undefined,
) {
if (!user || user.collection !== "users") return false;
export default function hasRoles(roles: NonNullable<User["roles"]>, user: User | null | undefined) {
if (!user) return false;
return roles.some((role) => user.roles?.some((urole) => urole === role || urole === "developer"));
}

View file

@ -1,5 +0,0 @@
import type { PayloadMcpApiKey, User } from "@/payload-types";
export function isPayloadUser(user: User | PayloadMcpApiKey | null | undefined): user is User {
return !!user && user.collection === "users";
}

View file

@ -85,10 +85,6 @@ 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",

View file

@ -1,208 +0,0 @@
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);
});
});