1
0
Fork 0

feat(world): game map collections with basemaps, map permissions, and meter proximity

Add maps basemap config (world size, image/tiles mode, zoom defaults, isActive), map-roads, map-zones, and resource-nodes collections with maps:* permissions. Track game-structures map placement, meter coordinates, and label visibility/priority. Bump proximityThreshold to meters and adapt side-op map creation plus test fixtures to the new required fields.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-16 23:13:57 -04:00
parent 28816b7084
commit 5f06039697
13 changed files with 518 additions and 10 deletions

View file

@ -445,7 +445,12 @@ async function resolveSideOpMap(
if (existing) return existing.id; if (existing) return existing.id;
const created = await payload.create({ const created = await payload.create({
collection: "maps", collection: "maps",
data: { name: customName }, data: {
name: customName,
worldSizeWidth: 8192,
worldSizeHeight: 8192,
basemapMode: "image",
},
overrideAccess: true, overrideAccess: true,
}); });
return created.id; return created.id;

View file

@ -89,10 +89,10 @@ export const GameRules: GlobalConfig = {
{ {
name: "proximityThreshold", name: "proximityThreshold",
type: "number", type: "number",
defaultValue: 10, defaultValue: 1000,
admin: { admin: {
description: 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.",
}, },
}, },
{ {

View file

@ -99,6 +99,45 @@ export const GameStructures: CollectionConfig = {
description: "The faction that owns this structure.", 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"), ...createStaffingFields("instance"),
{ {
label: "Geolocation", label: "Geolocation",
@ -107,9 +146,34 @@ export const GameStructures: CollectionConfig = {
{ {
name: "coordinates", name: "coordinates",
type: "point", 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", name: "storedResources",
type: "array", type: "array",

View file

@ -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.",
},
},
],
};

View file

@ -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).",
},
},
],
};

View file

@ -21,9 +21,116 @@ export const Maps: CollectionConfig = {
unique: true, unique: true,
}, },
{ {
name: "fileName", name: "isActive",
type: "text", type: "checkbox",
unique: true, 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,
},
],
}, },
], ],
}; };

View file

@ -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.",
},
},
],
};

View file

@ -17,6 +17,9 @@ import { Profiles } from "@/collections/users/Profiles";
import { Missions } from "@/collections/intelligence/Missions"; import { Missions } from "@/collections/intelligence/Missions";
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances"; import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
import { Maps } from "@/collections/world/Maps"; 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 { Campaigns } from "@/collections/intelligence/Campaigns";
import { Qualifications } from "@/collections/users/Qualifications"; import { Qualifications } from "@/collections/users/Qualifications";
import { MissionFiles } from "@/collections/server/MissionFiles"; import { MissionFiles } from "@/collections/server/MissionFiles";
@ -289,6 +292,9 @@ const collections = [
// World // World
Maps, Maps,
MapRoads,
MapZones,
ResourceNodes,
NarrativeEvents, NarrativeEvents,
// Story // Story

View file

@ -483,6 +483,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "admin:maps:manage", label: "Manage Admin Page" }, { 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", group: "Narrative Events",
permissions: [ permissions: [
@ -825,6 +836,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"maps:update", "maps:update",
"maps:delete", "maps:delete",
"admin:maps:manage", "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:create",
"narrative-events:read", "narrative-events:read",
"narrative-events:update", "narrative-events:update",

View file

@ -110,7 +110,7 @@ describe("Leadership evaluations", () => {
const map = (await payload.create({ const map = (await payload.create({
collection: "maps", collection: "maps",
data: { name: `${RUN} Map` }, data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as MissionMap; })) as unknown as MissionMap;

View file

@ -25,7 +25,7 @@ describe("Mission auto-complete", () => {
const map = (await payload.create({ const map = (await payload.create({
collection: "maps", collection: "maps",
data: { name: `${RUN} Map` }, data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as MissionMap; })) as unknown as MissionMap;

View file

@ -105,7 +105,7 @@ describe("Mission feedback reminder", () => {
const map = (await payload.create({ const map = (await payload.create({
collection: "maps", collection: "maps",
data: { name: `${RUN} Map` }, data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as MissionMap; })) as unknown as MissionMap;

View file

@ -38,7 +38,7 @@ describe("Respawn ticket settlement", () => {
const map = (await payload.create({ const map = (await payload.create({
collection: "maps", collection: "maps",
data: { name: `${RUN} Map` }, data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as MissionMap; })) as unknown as MissionMap;