diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index 373ddf0..f4cc738 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -445,7 +445,12 @@ async function resolveSideOpMap( if (existing) return existing.id; const created = await payload.create({ collection: "maps", - data: { name: customName }, + data: { + name: customName, + worldSizeWidth: 8192, + worldSizeHeight: 8192, + basemapMode: "image", + }, overrideAccess: true, }); return created.id; diff --git a/src/collections/game/GameRules.ts b/src/collections/game/GameRules.ts index 5d99bf7..d829f47 100644 --- a/src/collections/game/GameRules.ts +++ b/src/collections/game/GameRules.ts @@ -89,10 +89,10 @@ export const GameRules: GlobalConfig = { { name: "proximityThreshold", type: "number", - defaultValue: 10, + defaultValue: 1000, admin: { description: - "Distance threshold for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.", + "Distance threshold in real meters for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.", }, }, { diff --git a/src/collections/game/GameStructures.ts b/src/collections/game/GameStructures.ts index 17c20be..0c9fd92 100644 --- a/src/collections/game/GameStructures.ts +++ b/src/collections/game/GameStructures.ts @@ -99,6 +99,45 @@ export const GameStructures: CollectionConfig = { description: "The faction that owns this structure.", }, }, + { + name: "map", + type: "relationship", + relationTo: "maps", + admin: { + description: "The game map this structure sits on.", + }, + }, + { + name: "constructionStatus", + type: "select", + label: "Construction Status", + defaultValue: "complete", + options: [ + { label: "Awaiting Materials", value: "awaiting_materials" }, + { label: "Building", value: "building" }, + { label: "Complete", value: "complete" }, + ], + admin: { + description: + "Placed sites start awaiting materials. Once all blueprint materials are delivered construction starts immediately (timestamps below); the game tick flips past-deadline building sites to complete.", + }, + }, + { + name: "constructionStartedAt", + type: "date", + label: "Construction Started At", + admin: { + description: "When construction began (materials consumed). Empty while awaiting materials.", + }, + }, + { + name: "constructionCompletesAt", + type: "date", + label: "Construction Completes At", + admin: { + description: "The exact deadline at which the site becomes operational.", + }, + }, ...createStaffingFields("instance"), { label: "Geolocation", @@ -107,9 +146,34 @@ export const GameStructures: CollectionConfig = { { name: "coordinates", type: "point", + validate: () => true, + admin: { + description: "Map meters [x, y] on the structure's game map (not geographic degrees).", + }, }, ], }, + { + name: "labelVisible", + type: "checkbox", + defaultValue: true, + label: "Show Name Label", + admin: { + description: + "Renders the structure's name on the map. When several structures cluster together, the highest-priority label wins until you zoom in.", + }, + }, + { + name: "labelPriority", + type: "number", + defaultValue: 0, + min: 0, + max: 100, + admin: { + description: + "Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.", + }, + }, { name: "storedResources", type: "array", diff --git a/src/collections/world/MapRoads.ts b/src/collections/world/MapRoads.ts new file mode 100644 index 0000000..fc9cb63 --- /dev/null +++ b/src/collections/world/MapRoads.ts @@ -0,0 +1,115 @@ +import { CollectionConfig } from "payload"; +import { requirePermission } from "@/utils/access-control/hasPermission"; +import { validateHexColor } from "@/lib/map/style"; +import { validatePolyline } from "@/lib/map/points"; + +/** + * Road network segments for a map, used by ground shipment routing. + * Points are [x, y] tuples in real meters on the map's grid. Roads connect + * where endpoints (or points) coincide within the routing epsilon; author + * them so shared endpoints use identical coordinates. + */ +export const MapRoads: CollectionConfig = { + slug: "map-roads", + admin: { + group: "World", + useAsTitle: "name", + }, + access: { + read: ({ req }) => !!req.user, + create: requirePermission("maps:create"), + update: requirePermission("maps:update"), + delete: requirePermission("maps:delete"), + }, + fields: [ + { + name: "name", + type: "text", + admin: { + description: 'Road or route label (e.g. "Highway 1", "Kavala ridge trail").', + }, + }, + { + name: "description", + type: "textarea", + admin: { + description: "Shown in the road's tooltip and popup on the map.", + }, + }, + { + name: "map", + type: "relationship", + relationTo: "maps", + required: true, + }, + { + name: "surface", + type: "select", + required: true, + defaultValue: "paved", + options: [ + { label: "Paved", value: "paved" }, + { label: "Dirt", value: "dirt" }, + { label: "Trail", value: "trail" }, + ], + }, + { + name: "speedMultiplier", + type: "number", + required: true, + defaultValue: 1, + min: 0.1, + max: 5, + admin: { + description: + "Routing weight multiplier. Higher = faster/preferred (1 = neutral). Affects which route Dijkstra picks; fuel is charged on geometric length.", + }, + }, + { + name: "strokeColor", + type: "text", + validate: validateHexColor, + admin: { + description: "Line color as hex (e.g. #cbd5e1). Empty = default color for the surface.", + }, + }, + { + name: "strokeWeight", + type: "number", + min: 1, + max: 10, + defaultValue: 2, + admin: { + description: "Line thickness in pixels.", + }, + }, + { + name: "strokeStyle", + type: "select", + defaultValue: "solid", + options: [ + { label: "Solid", value: "solid" }, + { label: "Dashed", value: "dashed" }, + { label: "Dotted", value: "dotted" }, + ], + }, + { + name: "labelVisible", + type: "checkbox", + defaultValue: true, + label: "Show Name Label", + admin: { + description: "Renders the road's name along the line at zoom-dependent intervals.", + }, + }, + { + name: "points", + type: "json", + required: true, + validate: validatePolyline, + admin: { + description: "Array of [x, y] meter tuples in map order.", + }, + }, + ], +}; diff --git a/src/collections/world/MapZones.ts b/src/collections/world/MapZones.ts new file mode 100644 index 0000000..5e5ad27 --- /dev/null +++ b/src/collections/world/MapZones.ts @@ -0,0 +1,121 @@ +import { CollectionConfig } from "payload"; +import { requirePermission } from "@/utils/access-control/hasPermission"; +import { validateHexColor } from "@/lib/map/style"; +import { validatePolygon } from "@/lib/map/points"; + +/** + * GM-drawn terrain zones. Land blueprints cannot be placed inside water + * polygons; water blueprints require one. Default terrain (no zones on the + * map) accepts everything. + */ +export const MapZones: CollectionConfig = { + slug: "map-zones", + admin: { + group: "World", + useAsTitle: "name", + }, + access: { + read: ({ req }) => !!req.user, + create: requirePermission("maps:create"), + update: requirePermission("maps:update"), + delete: requirePermission("maps:delete"), + }, + fields: [ + { + name: "name", + type: "text", + admin: { + description: 'Zone label (e.g. "Altis bay", "Flood plain").', + }, + }, + { + name: "description", + type: "textarea", + admin: { + description: "Shown in the zone's tooltip and popup on the map.", + }, + }, + { + name: "map", + type: "relationship", + relationTo: "maps", + required: true, + }, + { + name: "type", + type: "select", + required: true, + options: [ + { label: "Water", value: "water" }, + { label: "Land", value: "land" }, + ], + admin: { + description: + "Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.", + }, + }, + { + name: "fillColor", + type: "text", + validate: validateHexColor, + admin: { + description: "Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.", + }, + }, + { + name: "strokeColor", + type: "text", + validate: validateHexColor, + admin: { + description: "Outline color as hex. Empty = default color for the type.", + }, + }, + { + name: "fillOpacity", + type: "number", + min: 0, + max: 1, + admin: { + description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).", + }, + }, + { + name: "strokeWeight", + type: "number", + min: 0, + max: 10, + defaultValue: 1, + admin: { + description: "Outline thickness in pixels. 0 hides the outline.", + }, + }, + { + name: "resources", + type: "relationship", + relationTo: "resources", + hasMany: true, + admin: { + description: + "Resources contained in this zone (e.g. an oil field). A zone with resources satisfies blueprint resourcesInRange requirements for any site inside it.", + }, + }, + { + name: "labelVisible", + type: "checkbox", + defaultValue: true, + label: "Show Name Label", + admin: { + description: "Renders the zone's name at the polygon's center.", + }, + }, + { + name: "points", + type: "json", + required: true, + validate: validatePolygon, + admin: { + description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).", + }, + }, + ], +}; diff --git a/src/collections/world/Maps.ts b/src/collections/world/Maps.ts index d21ca6f..bceeb54 100644 --- a/src/collections/world/Maps.ts +++ b/src/collections/world/Maps.ts @@ -21,9 +21,116 @@ export const Maps: CollectionConfig = { unique: true, }, { - name: "fileName", - type: "text", - unique: true, + name: "isActive", + type: "checkbox", + label: "Active", + defaultValue: true, + admin: { + description: "Inactive maps are hidden from the map page and cannot receive placements.", + }, + }, + { + label: "World Size (meters)", + type: "group", + fields: [ + { + name: "worldSizeWidth", + type: "number", + label: "Width (m)", + required: true, + min: 1, + defaultValue: 8192, + admin: { + description: "Map width in real meters (e.g. Altis is 8192).", + }, + }, + { + name: "worldSizeHeight", + type: "number", + label: "Height (m)", + required: true, + min: 1, + defaultValue: 8192, + admin: { + description: "Map height in real meters (e.g. Altis is 8192).", + }, + }, + ], + }, + { + label: "Basemap", + type: "group", + fields: [ + { + name: "basemapMode", + type: "select", + required: true, + defaultValue: "image", + options: [ + { label: "Image Upload", value: "image" }, + { label: "XYZ Tile Layer", value: "tiles" }, + ], + admin: { + description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.", + }, + }, + { + name: "basemapImage", + type: "upload", + relationTo: "media", + label: "Basemap Image", + admin: { + condition: (_data, siblingData) => siblingData?.basemapMode !== "tiles", + description: "Top-down map image. Rendered stretched across the full world size.", + }, + }, + { + name: "tileUrlTemplate", + type: "text", + label: "Tile URL Template", + admin: { + condition: (_data, siblingData) => siblingData?.basemapMode === "tiles", + description: "XYZ template, e.g. https://tiles.example.com/{z}/{x}/{y}.png", + }, + }, + { + name: "minZoom", + type: "number", + defaultValue: 0, + }, + { + name: "maxZoom", + type: "number", + defaultValue: 12, + }, + ], + }, + { + label: "Default View", + type: "group", + fields: [ + { + name: "defaultCenterX", + type: "number", + label: "Center X (m)", + admin: { + description: "Defaults to half the world width when empty.", + }, + }, + { + name: "defaultCenterY", + type: "number", + label: "Center Y (m)", + admin: { + description: "Defaults to half the world height when empty.", + }, + }, + { + name: "defaultZoom", + type: "number", + defaultValue: 2, + }, + ], }, ], }; diff --git a/src/collections/world/ResourceNodes.ts b/src/collections/world/ResourceNodes.ts new file mode 100644 index 0000000..8ce5d0b --- /dev/null +++ b/src/collections/world/ResourceNodes.ts @@ -0,0 +1,73 @@ +import { CollectionConfig } from "payload"; +import { requirePermission } from "@/utils/access-control/hasPermission"; +import { validatePosition } from "@/lib/map/points"; + +/** + * Map resource nodes (deposits, forests, wells, ...). Display + placement + * validation only: blueprints with resourcesInRange require a matching node + * within range. Nodes do not produce anything. + */ +export const ResourceNodes: CollectionConfig = { + slug: "resource-nodes", + admin: { + group: "World", + useAsTitle: "name", + }, + access: { + read: ({ req }) => !!req.user, + create: requirePermission("maps:create"), + update: requirePermission("maps:update"), + delete: requirePermission("maps:delete"), + }, + fields: [ + { + name: "name", + type: "text", + required: true, + admin: { + description: 'Node label (e.g. "North copper deposit").', + }, + }, + { + name: "description", + type: "textarea", + admin: { + description: "Shown in the node's tooltip and popup on the map.", + }, + }, + { + name: "map", + type: "relationship", + relationTo: "maps", + required: true, + }, + { + name: "resources", + type: "relationship", + relationTo: "resources", + hasMany: true, + required: true, + admin: { + description: "Every resource this node yields (e.g. copper AND oil).", + }, + }, + { + name: "position", + type: "json", + required: true, + validate: validatePosition, + admin: { + description: "[x, y] tuple in meters on the map grid.", + }, + }, + { + name: "richness", + type: "number", + defaultValue: 100, + min: 0, + admin: { + description: "Display-only abundance rating for the map UI.", + }, + }, + ], +}; diff --git a/src/payload.config.ts b/src/payload.config.ts index ecd9979..ed70f8f 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -17,6 +17,9 @@ import { Profiles } from "@/collections/users/Profiles"; import { Missions } from "@/collections/intelligence/Missions"; import { MissionAttendances } from "@/collections/intelligence/MissionAttendances"; import { Maps } from "@/collections/world/Maps"; +import { MapRoads } from "@/collections/world/MapRoads"; +import { MapZones } from "@/collections/world/MapZones"; +import { ResourceNodes } from "@/collections/world/ResourceNodes"; import { Campaigns } from "@/collections/intelligence/Campaigns"; import { Qualifications } from "@/collections/users/Qualifications"; import { MissionFiles } from "@/collections/server/MissionFiles"; @@ -289,6 +292,9 @@ const collections = [ // World Maps, + MapRoads, + MapZones, + ResourceNodes, NarrativeEvents, // Story diff --git a/src/permissions/index.ts b/src/permissions/index.ts index ae5319d..4ee2479 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -483,6 +483,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ { value: "admin:maps:manage", label: "Manage Admin Page" }, ], }, + { + group: "Map Features", + permissions: [ + { value: "map-roads:read", label: "Read Roads" }, + { value: "admin:map-roads:manage", label: "Manage Roads Admin Page" }, + { value: "map-zones:read", label: "Read Zones" }, + { value: "admin:map-zones:manage", label: "Manage Zones Admin Page" }, + { value: "resource-nodes:read", label: "Read Resource Nodes" }, + { value: "admin:resource-nodes:manage", label: "Manage Resource Nodes Admin Page" }, + ], + }, { group: "Narrative Events", permissions: [ @@ -825,6 +836,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) => "maps:update", "maps:delete", "admin:maps:manage", + "map-roads:read", + "admin:map-roads:manage", + "map-zones:read", + "admin:map-zones:manage", + "resource-nodes:read", + "admin:resource-nodes:manage", "narrative-events:create", "narrative-events:read", "narrative-events:update", diff --git a/tests/int/evaluations.int.spec.ts b/tests/int/evaluations.int.spec.ts index 3758bfb..01ad07a 100644 --- a/tests/int/evaluations.int.spec.ts +++ b/tests/int/evaluations.int.spec.ts @@ -110,7 +110,7 @@ describe("Leadership evaluations", () => { const map = (await payload.create({ collection: "maps", - data: { name: `${RUN} Map` }, + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, })) as unknown as MissionMap; diff --git a/tests/int/mission-lifecycle.int.spec.ts b/tests/int/mission-lifecycle.int.spec.ts index efc2a88..c323959 100644 --- a/tests/int/mission-lifecycle.int.spec.ts +++ b/tests/int/mission-lifecycle.int.spec.ts @@ -25,7 +25,7 @@ describe("Mission auto-complete", () => { const map = (await payload.create({ collection: "maps", - data: { name: `${RUN} Map` }, + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, })) as unknown as MissionMap; diff --git a/tests/int/mission-reminder.int.spec.ts b/tests/int/mission-reminder.int.spec.ts index 17ab958..b7a037f 100644 --- a/tests/int/mission-reminder.int.spec.ts +++ b/tests/int/mission-reminder.int.spec.ts @@ -105,7 +105,7 @@ describe("Mission feedback reminder", () => { const map = (await payload.create({ collection: "maps", - data: { name: `${RUN} Map` }, + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, })) as unknown as MissionMap; diff --git a/tests/int/respawn-tickets.int.spec.ts b/tests/int/respawn-tickets.int.spec.ts index 8a9f1f9..4e452e8 100644 --- a/tests/int/respawn-tickets.int.spec.ts +++ b/tests/int/respawn-tickets.int.spec.ts @@ -38,7 +38,7 @@ describe("Respawn ticket settlement", () => { const map = (await payload.create({ collection: "maps", - data: { name: `${RUN} Map` }, + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, })) as unknown as MissionMap;