"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 { 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>, user: { id: number | string }, permission: "maps:create" | "maps:update" | "maps:delete", ): Promise { 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>, 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 { 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 { 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 { 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 { 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 }; } }