1
0
Fork 0

Compare commits

..

15 commits

Author SHA1 Message Date
d6b2ffa5d2 chore(access-control): scaffold empty isAuthor helper stub 2026-08-25 13:27:48 -04:00
f1dd3c67ce feat(campaigns): add Zeus campaign-creation CTA to campaigns header 2026-08-25 13:27:48 -04:00
802873c7fa feat(structures): hero image header on structure detail page
When the structure type has an image, render it as a full-bleed
cover behind the page header with vertical and horizontal gradient
overlays; text shifts to white for contrast. Headers without an
image keep the previous plain layout.
2026-08-25 12:34:35 -04:00
fff9e82d62 feat(tools): scaffold reset-password CLI
Add src/tools/cli with a CliFlow helper (boxed headers, labeled log
lines) and a reset-password command stub wired to the prompt package.
Runnable via 'bun run u:reset-password'; interactive flow to follow.
2026-08-25 12:32:03 -04:00
41b9d62e15 feat(sidebar): add sparkle accent to mini XP display
Show a sparkle icon next to the XP number in the collapsed sidebar
XP strip; inline the level-name/XP spans per prettier.
2026-08-25 12:31:54 -04:00
3348466f3b fix(roster): tighten tab icon spacing and allow org chart scroll
Add gap-1.5 to the roster tab triggers so icons and labels are not
glued together, and let the org chart tab scroll horizontally instead
of overflowing the page.
2026-08-25 12:31:53 -04:00
7cfb650095 style(bot): capitalize attendance embed field labels
Title-case 'Operation Type' and 'Max Players' in the RSVP embed and
apply prettier formatting to the listener error log call.
2026-08-25 12:31:20 -04:00
9b909e85e6 refactor(arma-bridge): extract shared server guard and json coercion
requireArmaServer (src/lib/arma-bridge/server.ts) wraps the shared
bridge auth + game-servers lookup that every v1 route repeated;
asJson (json.ts) narrows untrusted request values to what Payload's
json field validation accepts (strings normalize to the fallback
instead of failing the write). All four v1 routes now use both.

Also document the ARMA_BRIDGE_API_KEY env var missed when the bridge
routes landed.
2026-08-25 12:30:50 -04:00
c628f8a90b feat(missions): auto-complete past missions via mission-tick bin
Add the mission-tick Payload bin (external cron): sweeps missions
whose status is Scheduled/Active and whose scheduled calendar day
(classification.startDateTime, server-local) has fully passed and
sets them to Completed, emitting a mission:auto-complete event per
mission. Draft and terminal statuses are never touched; idempotent.
Notifies clients via the game-tick SSE path.

Logic lives in src/lib/intelligence/missionLifecycle.ts with an
integration test in tests/int/mission-lifecycle.int.spec.ts.
2026-08-25 12:30:04 -04:00
de1230a757 fix(generate-mission): strip array row ids when cloning missions
Cloning a mission whose array rows still carry the source doc's row
ids trips a unique-constraint violation in the drizzle adapter,
surfacing as 'ValidationError: The following field is invalid: id'.
Walk the collection's field schema (groups, tabs, rows, nested
arrays) and strip row ids before create — schema-driven so Lexical
richText blobs are cloned verbatim.

Also adopt the bin file logger and the standard fatal-error exit
path.
2026-08-25 12:29:26 -04:00
e72b849774 feat(scripts): tee bin script logs to daily files
Add createBinLogger (src/scripts/lib/binFileLogger.ts): every log call
is appended synchronously to logs/<bin-key>/<YYYY-MM-DD>.log (UTC) in
addition to the console logger — sync writes because bin scripts
process.exit() immediately. Bin bodies are wrapped in try/catch so a
fatal error is logged to file and exits code 1. Override the directory
with BIN_LOG_DIR. logs/ is gitignored.

game-tick, market-tick, and processShipmentTick adopt the logger;
generate-mission follows with its clone fix; mission-tick lands with
its feature. Document BIN_LOG_DIR in .env.example and the logging in
AGENTS.md / README.
2026-08-25 12:29:07 -04:00
824b03ec49 feat(missions): add redacted objective toggle
Mission objectives gain a redacted checkbox. Redacted objectives are
shown block-redacted to players; mission managers, authors, and Zeus
see the real text (via tooltip) and can toggle the flag from the
mission detail page. Toggles emit mission:objective-redacted-toggle
events. Also registers the mission:auto-complete event type used by
the upcoming mission-tick bin.
2026-08-25 12:27:52 -04:00
c0d00fc113 fix(auth): reject MCP API-key sessions in user auth paths
With the MCP plugin enabled, payload.auth() can return a
payload-mcp-api-keys doc instead of a User. Add an isPayloadUser
type guard and use it in every server action, page, and route that
treats the auth result as a real user, so API-key sessions can no
longer pass user auth checks. hasRoles now explicitly requires a
users-collection doc.
2026-08-25 12:27:35 -04:00
b70ff4dff8 feat(mcp): enable Payload MCP plugin
Register mcpPlugin on the Payload config. Auth now also accepts
payload-mcp-api-keys, so the generated types carry the new
collection and the user union widens accordingly. Follow-up commits
guard user auth paths against these API-key sessions.

Also apply incidental prettier formatting to generatePlainText().
2026-08-25 12:27:01 -04:00
83790a629b chore(deps): bump dependencies
Bump radix-ui primitives, tailwindcss/postcss, graphql, nodemailer,
drizzle-kit, eslint, prettier, @types/node and other runtime/dev deps.

The refreshed lockfile also carries entries for two new packages
(@payloadcms/plugin-mcp, prompt) whose package.json additions land
together with their features in follow-up commits.
2026-08-25 12:20:38 -04:00
49 changed files with 1961 additions and 1045 deletions

View file

@ -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
View file

@ -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

View file

@ -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).

View file

@ -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)**.

972
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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) => (

View file

@ -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} />

View file

@ -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 };
}
}

View file

@ -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 = {

View file

@ -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");
}

View file

@ -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);

View file

@ -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");
}

View file

@ -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",

View file

@ -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");
}

View file

@ -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>}

View file

@ -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({

View file

@ -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 });
}

View file

@ -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,
})),
});
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

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,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(() => {

View file

@ -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,
},
],
},
{

View file

@ -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) =>

View file

@ -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>
) : (

View file

@ -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>
);
}

View file

@ -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">

View 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;
}

View 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 };
}

View 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
View 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);
}

View file

@ -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".

View file

@ -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,

View file

@ -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);
}
};

View file

@ -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);
};

View 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";
}

View file

@ -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);
}
};

View 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);
}
};

View file

@ -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
View 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(" ")}`);
}
}

View 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");

View file

@ -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"));
}

View file

View 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";
}

View file

@ -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",

View 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);
});
});