- 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)
531 lines
17 KiB
TypeScript
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 };
|
|
}
|
|
}
|