1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/map/actions.ts
Z8MB1E 9bbe44d38d feat(map): world map picker entry, intel fields, node icons, generic markers
- Maps gain worldMap flag, description, freeform weather, latitude/longitude,
  easeOfAccess and factionControl; picker cards render them as stat tags
- resource-nodes gain a shared icon set and may exist with no resources as
  plain marker nodes (placement ignores marker-only nodes)
- sidebar gains the World Map entry; Infrastructure section gets defaultOpen
- migrations 20260921_120000/123000/130500/134000; includes the generated
  types/schema sweep (currency emblem fields land in the next commit)
2026-09-21 20:47:46 -04:00

531 lines
17 KiB
TypeScript

"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { GameStructure } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
validatePlacement,
type BlueprintTerrain,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
import { maybeStartConstruction } from "@/lib/map/construction";
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
import { validateHexColor } from "@/lib/map/style";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
import type { ResourceNode } from "@/payload-types";
interface ActionResult {
success: boolean;
error?: string;
data?: unknown;
}
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 placeStructure(params: {
mapId: number;
blueprintId: number;
name: string;
factionId?: number | null;
parentStructureId?: number | null;
x: number;
y: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasLogisticsQualification(payload, user))) {
return {
success: false,
error: "Logistics qualification required to place structures.",
};
}
const { mapId, blueprintId, name, x, y } = params;
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return { success: false, error: "Valid coordinates are required." };
}
if (!name || name.trim().length === 0) {
return { success: false, error: "A display name is required." };
}
const map = await payload
.findByID({ collection: "maps", id: mapId, depth: 0 })
.catch(() => null);
if (!map) {
return { success: false, error: "Map not found." };
}
if (!map.isActive) {
return { success: false, error: "This map is not active." };
}
const worldW = map.worldSizeWidth ?? 8192;
const worldH = map.worldSizeHeight ?? 8192;
if (x < 0 || x > worldW || y < 0 || y > worldH) {
return { success: false, error: "Site is outside the map bounds." };
}
const blueprint = await payload
.findByID({ collection: "structures", id: blueprintId, depth: 1 })
.catch(() => null);
if (!blueprint) {
return { success: false, error: "Structure blueprint not found." };
}
if (params.factionId != null) {
const faction = await payload
.findByID({ collection: "factions", id: params.factionId, depth: 0 })
.catch(() => null);
if (!faction) {
return { success: false, error: "Faction not found." };
}
}
let parentStructureId: number | null = null;
if (params.parentStructureId != null) {
const hub = await payload
.findByID({
collection: "game-structures",
id: params.parentStructureId,
depth: 0,
})
.catch(() => null);
if (!hub) {
return { success: false, error: "Compound hub not found." };
}
if ((hub as unknown as GameStructure).map !== mapId) {
return { success: false, error: "The compound hub is on a different map." };
}
if ((hub as unknown as GameStructure).parentStructure != null) {
return { success: false, error: "Compound hubs cannot themselves be compound children." };
}
parentStructureId = hub.id;
}
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
payload.find({
collection: "map-zones",
where: { map: { equals: mapId } },
depth: 0,
limit: 5000,
}),
payload.find({
collection: "resource-nodes",
where: { map: { equals: mapId } },
depth: 0,
limit: 5000,
}),
payload.find({
collection: "game-structures",
where: { and: [{ map: { equals: mapId } }, { coordinates: { exists: true } }] },
depth: 0,
limit: 1000,
}),
]);
const zones: ZoneInput[] = zonesRes.docs.map((zone) => ({
id: zone.id,
name: zone.name ?? null,
type: zone.type,
resources: ((zone.resources ?? []) as number[]) ?? [],
points: (zone.points ?? []) as MapPoint[],
}));
const nodes: NodeInput[] = nodesRes.docs.map((node) => ({
id: node.id,
resources: ((node.resources ?? []) as number[]) ?? [],
position: (node.position ?? [0, 0]) as MapPoint,
}));
const structures: StructureInput[] = structuresRes.docs
.map((doc) => {
const structure = doc as unknown as GameStructure;
return {
id: structure.id,
type: typeof structure.type === "object" ? structure.type.id : structure.type,
coordinates: structure.coordinates as MapPoint | null,
};
})
.filter((structure): structure is StructureInput & { coordinates: MapPoint } =>
isPointArray(structure.coordinates, 1),
);
const point: MapPoint = [x, y];
const terrain = (blueprint.terrainType ?? "land") as BlueprintTerrain;
const validation = validatePlacement({
zones,
nodes,
structures,
blueprintTerrain: terrain,
resourcesInRange: (blueprint.resourcesInRange ?? []).map((requirement) => ({
resource:
typeof requirement.resource === "object"
? requirement.resource?.id
: requirement.resource,
range: requirement.range ?? null,
})),
structuresInRange: (blueprint.structuresInRange ?? []).map((requirement) => ({
structure:
typeof requirement.structure === "object"
? requirement.structure?.id
: requirement.structure,
range: requirement.range ?? null,
})),
point,
});
if (!validation.allowed) {
return { success: false, error: validation.error ?? "Site is not valid for placement." };
}
const structure = await payload.create({
collection: "game-structures",
data: {
name: name.trim(),
type: blueprintId,
faction: params.factionId ?? null,
map: mapId,
coordinates: point,
constructionStatus: "awaiting_materials",
parentStructure: parentStructureId,
},
});
// Blueprints without materials start building immediately (the helper
// no-ops while any material is still missing).
try {
await maybeStartConstruction(payload, structure.id);
} catch {
// The site starts on the next delivery instead.
}
await emitGameEvent(payload, {
type: EventTypes.StructurePlaced,
message: `Construction site "${name.trim()}" (${blueprint.name}) placed at ${Math.round(x)}, ${Math.round(y)} on ${map.name}`,
actor: user.id,
structure: structure.id,
targetCollection: "game-structures",
targetId: structure.id,
data: {
mapId,
blueprintId,
blueprintName: blueprint.name,
point,
constructionDurationMinutes: blueprint.constructionDurationMinutes,
},
});
return { success: true, data: structure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export interface MapRoadInput {
id?: number | null;
mapId: number;
name?: string | null;
description?: string | null;
surface: "paved" | "dirt" | "trail";
speedMultiplier: number;
strokeColor?: string | null;
strokeWeight?: number | null;
strokeStyle?: "solid" | "dashed" | "dotted" | null;
labelVisible?: boolean | null;
points: MapPoint[];
}
export interface MapZoneInput {
id?: number | null;
mapId: number;
name?: string | null;
description?: string | null;
type: "water" | "land" | "territory" | "border" | "other";
resourceIds?: number[];
fillColor?: string | null;
strokeColor?: string | null;
fillOpacity?: number | null;
strokeWeight?: number | null;
labelVisible?: boolean | null;
points: MapPoint[];
}
export interface MapNodeInput {
id?: number | null;
mapId: number;
name: string;
description?: string | null;
resourceIds: number[];
position: MapPoint;
richness?: number | null;
icon?: string | null;
}
async function requireMapPermission(
payload: Awaited<ReturnType<typeof getPayload>>,
user: { id: number | string },
permission: "maps:create" | "maps:update" | "maps:delete",
): Promise<boolean> {
return hasPermission(payload, user, permission);
}
function relationshipMapId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value && typeof value === "object" && "id" in value) {
return (value as { id: number }).id;
}
return null;
}
type MapFeatureCollection = "map-roads" | "map-zones" | "resource-nodes";
/** When editing, the feature must exist and belong to the map being edited. */
async function existingFeatureOnMap(
payload: Awaited<ReturnType<typeof getPayload>>,
collection: MapFeatureCollection,
id: number,
mapId: number,
label: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const existing = await payload.findByID({ collection, id, depth: 0 }).catch(() => null);
if (!existing) return { ok: false, error: `${label} not found.` };
if (relationshipMapId(existing.map) !== mapId) {
return { ok: false, error: `This ${label.toLowerCase()} belongs to a different map.` };
}
return { ok: true };
}
export async function saveMapRoad(input: MapRoadInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (!isPointArray(input.points, 2)) {
return { success: false, error: "A road requires at least two points." };
}
if (input.speedMultiplier <= 0) {
return { success: false, error: "Speed multiplier must be greater than zero." };
}
const colorCheck = validateHexColor(input.strokeColor);
if (colorCheck !== true) return { success: false, error: colorCheck };
const map = await payload
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
.catch(() => null);
if (!map) return { success: false, error: "Map not found." };
const roadData = {
name: input.name?.trim() || null,
description: input.description?.trim() || null,
surface: input.surface,
speedMultiplier: input.speedMultiplier,
strokeColor: input.strokeColor?.trim() || null,
strokeWeight: input.strokeWeight ?? null,
strokeStyle: input.strokeStyle ?? null,
labelVisible: input.labelVisible ?? null,
points: input.points,
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"map-roads",
input.id!,
input.mapId,
"Road",
);
if (!existing.ok) return { success: false, error: existing.error };
const road = await payload.update({
collection: "map-roads",
id: input.id!,
data: roadData,
});
return { success: true, data: road };
}
const road = await payload.create({
collection: "map-roads",
data: { ...roadData, map: input.mapId },
});
return { success: true, data: road };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function saveMapZone(input: MapZoneInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (!isPointArray(input.points, 3)) {
return { success: false, error: "A zone requires at least three points." };
}
for (const color of [input.fillColor, input.strokeColor]) {
const colorCheck = validateHexColor(color);
if (colorCheck !== true) return { success: false, error: colorCheck };
}
const map = await payload
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
.catch(() => null);
if (!map) return { success: false, error: "Map not found." };
const zoneData = {
name: input.name?.trim() || null,
description: input.description?.trim() || null,
type: input.type,
resources: input.resourceIds ?? [],
fillColor: input.fillColor?.trim() || null,
strokeColor: input.strokeColor?.trim() || null,
fillOpacity: input.fillOpacity ?? null,
strokeWeight: input.strokeWeight ?? null,
labelVisible: input.labelVisible ?? null,
points: input.points,
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"map-zones",
input.id!,
input.mapId,
"Zone",
);
if (!existing.ok) return { success: false, error: existing.error };
const zone = await payload.update({
collection: "map-zones",
id: input.id!,
data: zoneData,
});
return { success: true, data: zone };
}
const zone = await payload.create({
collection: "map-zones",
data: { ...zoneData, map: input.mapId },
});
return { success: true, data: zone };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (validatePosition(input.position) !== true) {
return { success: false, error: "A node requires a single [x, y] position." };
}
const resourceIds = [...new Set(input.resourceIds)];
if (resourceIds.length > 0) {
const resources = await payload.find({
collection: "resources",
where: { id: { in: resourceIds } },
depth: 0,
limit: resourceIds.length,
});
if (resources.docs.length !== resourceIds.length) {
return { success: false, error: "One or more resources not found." };
}
}
// An empty resource list makes the node a plain marker: no resource
// validation, and placement's resourcesInRange never matches it.
const icon = input.icon ?? "marker";
if (!NODE_ICON_OPTIONS.some((option) => option.value === icon)) {
return { success: false, error: "Unknown node icon." };
}
const nodeData = {
name: input.name.trim(),
description: input.description?.trim() || null,
resources: resourceIds,
position: input.position,
richness: input.richness ?? 100,
icon: icon as ResourceNode["icon"],
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"resource-nodes",
input.id!,
input.mapId,
"Node",
);
if (!existing.ok) return { success: false, error: existing.error };
const node = await payload.update({
collection: "resource-nodes",
id: input.id!,
data: nodeData,
});
return { success: true, data: node };
}
const node = await payload.create({
collection: "resource-nodes",
data: { ...nodeData, map: input.mapId },
});
return { success: true, data: node };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function deleteMapFeature(
kind: "road" | "zone" | "node",
id: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await requireMapPermission(payload, user, "maps:delete"))) {
return { success: false, error: "Map configuration permission required." };
}
const collection =
kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes";
await payload.delete({ collection, id, overrideAccess: false });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}