- New mission-map-markers collection (permission-gated, removed with its mission via a Missions beforeDelete hook) plus an opt-in classification.planningMapEnabled flag on missions. - The mission page gains a planning section: place and edit Arma markers (icons, ellipses, rectangles, polylines) with debounced saves, an SQF copy export, and an Arma grid readout. Everyone who can read the mission sees the plan once enabled; editing requires missions:update or mission ownership. - Base layers come from GET /api/map-features?layers=base, which skips the structure, node and shipment queries; marker icons render tinted by color exactly like in-game. - Includes the hand-written migration, the missionMap service lib, server actions emitting mission:map-marker-* events, and integration plus e2e coverage.
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { Map as MissionMapDoc, Mission, User } from "@/payload-types";
|
|
import {
|
|
ARMA_MARKER_BRUSHES,
|
|
ARMA_MARKER_COLORS,
|
|
ARMA_MARKER_TYPES,
|
|
markerTypeOf,
|
|
markerTypesByCategory,
|
|
resolveMarkerRgba,
|
|
rgbaToHex,
|
|
} from "@/lib/arma-markers";
|
|
import {
|
|
areaOutline,
|
|
armaGridRef,
|
|
hatchSegments,
|
|
markerFromDoc,
|
|
markersToSqf,
|
|
normalizeMarkerInput,
|
|
toArmaPosition,
|
|
type MissionMarker,
|
|
} from "@/lib/arma-markers/missionMarkers";
|
|
import {
|
|
canEditMissionPlan,
|
|
clearMissionMarkers,
|
|
createMissionMarker,
|
|
deleteMissionMarker,
|
|
findVisibleMission,
|
|
listMissionMarkers,
|
|
updateMissionMarker,
|
|
} from "@/lib/intelligence/missionMap";
|
|
import { existsSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const WORLD = { width: 8192, height: 8192 };
|
|
|
|
describe("Arma marker catalog", () => {
|
|
it("contains the core vanilla classes with resolved names", () => {
|
|
expect(markerTypeOf("b_inf")?.name).toBe("Infantry");
|
|
expect(markerTypeOf("B_INF")?.className).toBe("b_inf");
|
|
expect(markerTypeOf("mil_dot")).not.toBeNull();
|
|
expect(markerTypeOf("hd_dot")).not.toBeNull();
|
|
expect(ARMA_MARKER_COLORS.some((c) => c.className === "ColorRed")).toBe(true);
|
|
expect(ARMA_MARKER_BRUSHES.some((b) => b.className === "Grid")).toBe(true);
|
|
});
|
|
|
|
it("ships an icon file for every marker type", () => {
|
|
const missing = ARMA_MARKER_TYPES.filter(
|
|
(type) => !existsSync(join(process.cwd(), "public", type.iconUrl)),
|
|
);
|
|
expect(missing.map((type) => type.className)).toEqual([]);
|
|
});
|
|
|
|
it("groups every type under a category", () => {
|
|
const grouped = markerTypesByCategory().reduce((sum, group) => sum + group.types.length, 0);
|
|
expect(grouped).toBe(ARMA_MARKER_TYPES.length);
|
|
});
|
|
|
|
it("resolves Default to the type's own (side) color", () => {
|
|
expect(rgbaToHex(resolveMarkerRgba("Default", "b_inf"))).toBe(rgbaToHex([0, 0.3, 0.6, 1]));
|
|
expect(rgbaToHex(resolveMarkerRgba("ColorRed", "b_inf"))).toBe("#e60000");
|
|
expect(rgbaToHex(resolveMarkerRgba("Default", null))).toBe("#000000");
|
|
});
|
|
});
|
|
|
|
describe("Mission marker validation", () => {
|
|
it("accepts and normalizes an icon", () => {
|
|
const result = normalizeMarkerInput(
|
|
{
|
|
shape: "icon",
|
|
markerType: "b_inf",
|
|
color: "ColorRed",
|
|
text: " Alpha\nSquad ",
|
|
position: [100.456, 200],
|
|
size: [20, 20],
|
|
direction: -90,
|
|
alpha: 3,
|
|
},
|
|
WORLD,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.data.text).toBe("Alpha Squad");
|
|
expect(result.data.position).toEqual([100.46, 200]);
|
|
expect(result.data.size).toEqual([8, 8]);
|
|
expect(result.data.direction).toBe(270);
|
|
expect(result.data.alpha).toBe(1);
|
|
expect(result.data.brush).toBeNull();
|
|
});
|
|
|
|
it("rejects unknown classes, bad shapes and off-world positions", () => {
|
|
expect(
|
|
normalizeMarkerInput({ shape: "icon", markerType: "nope", position: [1, 1] }, WORLD),
|
|
).toMatchObject({ ok: false });
|
|
expect(
|
|
normalizeMarkerInput(
|
|
{ shape: "icon", markerType: "b_inf", color: "ColorPlaid", position: [1, 1] },
|
|
WORLD,
|
|
),
|
|
).toMatchObject({ ok: false });
|
|
expect(normalizeMarkerInput({ shape: "star", position: [1, 1] }, WORLD)).toMatchObject({
|
|
ok: false,
|
|
});
|
|
expect(
|
|
normalizeMarkerInput({ shape: "icon", markerType: "b_inf", position: [99999, 1] }, WORLD),
|
|
).toMatchObject({ ok: false });
|
|
expect(
|
|
normalizeMarkerInput({ shape: "rectangle", position: [10, 10], size: [0.1, 5] }, WORLD),
|
|
).toMatchObject({ ok: false });
|
|
expect(normalizeMarkerInput({ shape: "polyline", points: [[1, 1]] }, WORLD)).toMatchObject({
|
|
ok: false,
|
|
});
|
|
});
|
|
|
|
it("defaults area brush and derives line position", () => {
|
|
const area = normalizeMarkerInput({ shape: "ellipse", position: [10, 10], size: [50] }, WORLD);
|
|
expect(area.ok && area.data.brush).toBe("Solid");
|
|
expect(area.ok && area.data.size).toEqual([50, 50]);
|
|
const line = normalizeMarkerInput(
|
|
{
|
|
shape: "polyline",
|
|
points: [
|
|
[5, 6],
|
|
[7, 8],
|
|
],
|
|
},
|
|
WORLD,
|
|
);
|
|
expect(line.ok && line.data.position).toEqual([5, 6]);
|
|
});
|
|
|
|
it("drops corrupt stored rows instead of throwing", () => {
|
|
expect(markerFromDoc({ id: 1, shape: "icon", position: "bad" })).toBeNull();
|
|
expect(
|
|
markerFromDoc({ id: 2, shape: "polyline", position: [1, 1], points: [[1, 1]] }),
|
|
).toBeNull();
|
|
expect(markerFromDoc({ id: 3, shape: "icon", position: [1, 2] })?.position).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
describe("Mission marker geometry and export", () => {
|
|
it("rotates area outlines clockwise from north", () => {
|
|
// 100 m east-west half-width, 10 m north-south, rotated 90 deg: now long
|
|
// north-south. y runs south on the map.
|
|
const ring = areaOutline("rectangle", [1000, 1000], [100, 10], 90);
|
|
const xs = ring.map((p) => Math.round(p[0]));
|
|
const ys = ring.map((p) => Math.round(p[1]));
|
|
expect(Math.max(...xs) - Math.min(...xs)).toBe(20);
|
|
expect(Math.max(...ys) - Math.min(...ys)).toBe(200);
|
|
expect(areaOutline("ellipse", [0, 0], [10, 10], 0, 32)).toHaveLength(32);
|
|
});
|
|
|
|
it("clips hatch lines to the area", () => {
|
|
const square = areaOutline("rectangle", [50, 50], [50, 50], 0);
|
|
const lines = hatchSegments(square, 0, 10);
|
|
expect(lines).toHaveLength(10);
|
|
for (const [a, b] of lines) {
|
|
expect(a[0]).toBeCloseTo(0, 5);
|
|
expect(b[0]).toBeCloseTo(100, 5);
|
|
expect(a[1]).toBe(b[1]);
|
|
}
|
|
expect(hatchSegments(square, 0, 0)).toEqual([]);
|
|
});
|
|
|
|
it("flips y for Arma positions and grid refs", () => {
|
|
expect(toArmaPosition([100, 8000], 8192)).toEqual([100, 192]);
|
|
expect(armaGridRef([4567, 8192 - 1234], 8192)).toBe("045 012");
|
|
expect(armaGridRef([4567, 8192 - 1234], 8192, 8)).toBe("0456 0123");
|
|
});
|
|
|
|
it("exports a createMarker script", () => {
|
|
const markers: MissionMarker[] = [
|
|
{
|
|
id: 7,
|
|
shape: "icon",
|
|
markerType: "b_inf",
|
|
color: "ColorBLUFOR",
|
|
brush: null,
|
|
text: 'Say "hi"',
|
|
position: [100, 8092],
|
|
size: [1, 1],
|
|
direction: 45,
|
|
alpha: 1,
|
|
points: [],
|
|
},
|
|
{
|
|
id: 8,
|
|
shape: "polyline",
|
|
markerType: null,
|
|
color: "ColorRed",
|
|
brush: null,
|
|
text: "",
|
|
position: [0, 0],
|
|
size: [1, 1],
|
|
direction: 0,
|
|
alpha: 0.5,
|
|
points: [
|
|
[0, 8192],
|
|
[10, 8182],
|
|
],
|
|
},
|
|
];
|
|
const sqf = markersToSqf(markers, 8192);
|
|
expect(sqf).toContain('_m = createMarker ["ptf_plan_7", [100, 100]];');
|
|
expect(sqf).toContain('_m setMarkerType "b_inf";');
|
|
expect(sqf).toContain('_m setMarkerText "Say ""hi""";');
|
|
expect(sqf).toContain("_m setMarkerDir 45;");
|
|
expect(sqf).toContain('_m setMarkerShape "POLYLINE";');
|
|
expect(sqf).toContain("_m setMarkerPolyline [0, 0, 10, 10];");
|
|
expect(sqf).toContain("_m setMarkerAlpha 0.5;");
|
|
});
|
|
});
|
|
|
|
describe("Mission planning map service", () => {
|
|
let payload: Payload;
|
|
const RUN = `mmap-${Date.now().toString(36)}`;
|
|
let mapId: number;
|
|
let mission: Mission;
|
|
let creator: User;
|
|
let editor: User;
|
|
let outsider: User;
|
|
let roleId: number;
|
|
const userIds: number[] = [];
|
|
|
|
const makeUser = async (username: string, roleDocs: number[] = []): Promise<User> => {
|
|
const user = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username,
|
|
discordUsername: username,
|
|
displayName: "MISSION MAP TEST",
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test123",
|
|
roles: ["user"],
|
|
roleDocs,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
userIds.push(user.id);
|
|
return user;
|
|
};
|
|
|
|
const makeMission = async (label: string, createdBy: number | null) =>
|
|
(await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
summary: "Planning map test mission",
|
|
operationType: "main",
|
|
createdBy,
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
startDateTime: new Date(Date.now() + 2 * 86_400_000).toISOString(),
|
|
estimatedDuration: 60,
|
|
planningMapEnabled: true,
|
|
},
|
|
ownershipAndStatus: { authors: [creator.id], status: "Scheduled", visibility: "unit" },
|
|
briefing: [],
|
|
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
|
|
missionRoles: { maxPlayers: 32, providedRoles: [] },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 1,
|
|
})) as unknown as Mission;
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config });
|
|
const map = (await payload.create({
|
|
collection: "maps",
|
|
data: {
|
|
name: `${RUN} Map`,
|
|
worldSizeWidth: 4096,
|
|
worldSizeHeight: 4096,
|
|
basemapMode: "image",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as MissionMapDoc;
|
|
mapId = map.id;
|
|
|
|
const role = await payload.create({
|
|
collection: "roles",
|
|
data: { name: `${RUN}-planner`, slug: `${RUN}-planner`, permissions: ["missions:update"] },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
roleId = role.id;
|
|
|
|
creator = await makeUser(`${RUN}-creator`);
|
|
editor = await makeUser(`${RUN}-editor`, [roleId]);
|
|
outsider = await makeUser(`${RUN}-outsider`);
|
|
mission = await makeMission("Op", creator.id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!payload) return;
|
|
if (mission)
|
|
await payload
|
|
.delete({ collection: "missions", id: mission.id, overrideAccess: true })
|
|
.catch(() => {});
|
|
for (const id of userIds) {
|
|
for (const collection of ["bank-accounts", "profiles"] as const) {
|
|
const field = collection === "bank-accounts" ? "ownerUser" : "user";
|
|
const docs = await payload
|
|
.find({ collection, where: { [field]: { equals: id } }, depth: 0, overrideAccess: true })
|
|
.catch(() => null);
|
|
for (const doc of docs?.docs ?? []) {
|
|
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
}
|
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "roles", id: roleId, overrideAccess: true }).catch(() => {});
|
|
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
|
});
|
|
|
|
it("lets the creator and missions:update holders edit, nobody else", async () => {
|
|
expect(await canEditMissionPlan(payload, creator, mission)).toBe(true);
|
|
expect(await canEditMissionPlan(payload, editor, mission)).toBe(true);
|
|
expect(await canEditMissionPlan(payload, outsider, mission)).toBe(false);
|
|
expect(await canEditMissionPlan(payload, null, mission)).toBe(false);
|
|
});
|
|
|
|
it("finds unit-visible missions with the world size", async () => {
|
|
const visible = await findVisibleMission(payload, outsider, mission.id);
|
|
expect(visible?.id).toBe(mission.id);
|
|
expect(visible?.classification.planningMapEnabled).toBe(true);
|
|
expect(await findVisibleMission(payload, outsider, -1)).toBeNull();
|
|
});
|
|
|
|
it("creates, updates, lists and deletes markers within one mission", async () => {
|
|
const icon = await createMissionMarker(
|
|
payload,
|
|
mission,
|
|
{
|
|
shape: "icon",
|
|
markerType: "o_armor",
|
|
color: "Default",
|
|
text: "Tank",
|
|
position: [500, 600],
|
|
},
|
|
creator.id,
|
|
);
|
|
expect(icon.markerType).toBe("o_armor");
|
|
|
|
const area = await createMissionMarker(
|
|
payload,
|
|
mission,
|
|
{
|
|
shape: "rectangle",
|
|
brush: "Grid",
|
|
color: "ColorRed",
|
|
position: [1000, 1000],
|
|
size: [200, 50],
|
|
direction: 30,
|
|
},
|
|
editor.id,
|
|
);
|
|
const moved = await updateMissionMarker(payload, mission, area.id, {
|
|
...area,
|
|
position: [1100, 900],
|
|
});
|
|
expect(moved.position).toEqual([1100, 900]);
|
|
expect(moved.size).toEqual([200, 50]);
|
|
expect(moved.direction).toBe(30);
|
|
|
|
// Outside the 4096 m world (+2 km margin): rejected.
|
|
await expect(
|
|
updateMissionMarker(payload, mission, area.id, { ...area, position: [9000, 9000] }),
|
|
).rejects.toThrow("on the map");
|
|
|
|
const listed = await listMissionMarkers(payload, mission.id);
|
|
expect(listed.map((marker) => marker.id)).toEqual([icon.id, area.id]);
|
|
|
|
// A marker id from another mission cannot be touched through this one.
|
|
const other = await makeMission("Other", null);
|
|
try {
|
|
await expect(deleteMissionMarker(payload, other.id, icon.id)).rejects.toThrow(
|
|
"Marker not found",
|
|
);
|
|
} finally {
|
|
await payload.delete({ collection: "missions", id: other.id, overrideAccess: true });
|
|
}
|
|
|
|
await deleteMissionMarker(payload, mission.id, icon.id);
|
|
expect((await listMissionMarkers(payload, mission.id)).map((m) => m.id)).toEqual([area.id]);
|
|
expect(await clearMissionMarkers(payload, mission.id)).toBe(1);
|
|
expect(await listMissionMarkers(payload, mission.id)).toEqual([]);
|
|
});
|
|
|
|
it("removes a mission's markers when the mission is deleted", async () => {
|
|
const doomed = await makeMission("Doomed", creator.id);
|
|
const marker = await createMissionMarker(
|
|
payload,
|
|
doomed,
|
|
{
|
|
shape: "polyline",
|
|
color: "ColorBlack",
|
|
points: [
|
|
[1, 1],
|
|
[50, 50],
|
|
],
|
|
},
|
|
creator.id,
|
|
);
|
|
await payload.delete({ collection: "missions", id: doomed.id, overrideAccess: true });
|
|
const left = await payload
|
|
.findByID({ collection: "mission-map-markers", id: marker.id, overrideAccess: true })
|
|
.catch(() => null);
|
|
expect(left).toBeNull();
|
|
});
|
|
|
|
it("keeps the collection closed to plain users over REST/local access", async () => {
|
|
await expect(
|
|
payload.find({
|
|
collection: "mission-map-markers",
|
|
user: outsider,
|
|
overrideAccess: false,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|