1
0
Fork 0

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)
This commit is contained in:
Jason Fraley 2026-09-21 20:47:46 -04:00
parent 679a0f7879
commit 9bbe44d38d
22 changed files with 1033 additions and 101 deletions

View file

@ -17,6 +17,8 @@ import {
import { maybeStartConstruction } from "@/lib/map/construction"; import { maybeStartConstruction } from "@/lib/map/construction";
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points"; import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
import { validateHexColor } from "@/lib/map/style"; import { validateHexColor } from "@/lib/map/style";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
import type { ResourceNode } from "@/payload-types";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -281,6 +283,7 @@ export interface MapNodeInput {
resourceIds: number[]; resourceIds: number[];
position: MapPoint; position: MapPoint;
richness?: number | null; richness?: number | null;
icon?: string | null;
} }
async function requireMapPermission( async function requireMapPermission(
@ -450,9 +453,7 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
return { success: false, error: "A node requires a single [x, y] position." }; return { success: false, error: "A node requires a single [x, y] position." };
} }
const resourceIds = [...new Set(input.resourceIds)]; const resourceIds = [...new Set(input.resourceIds)];
if (resourceIds.length === 0) { if (resourceIds.length > 0) {
return { success: false, error: "A node requires at least one resource." };
}
const resources = await payload.find({ const resources = await payload.find({
collection: "resources", collection: "resources",
where: { id: { in: resourceIds } }, where: { id: { in: resourceIds } },
@ -462,6 +463,14 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
if (resources.docs.length !== resourceIds.length) { if (resources.docs.length !== resourceIds.length) {
return { success: false, error: "One or more resources not found." }; 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 = { const nodeData = {
name: input.name.trim(), name: input.name.trim(),
@ -469,6 +478,7 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
resources: resourceIds, resources: resourceIds,
position: input.position, position: input.position,
richness: input.richness ?? 100, richness: input.richness ?? 100,
icon: icon as ResourceNode["icon"],
}; };
if (editing) { if (editing) {

View file

@ -0,0 +1,60 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Map as MapIcon } from "lucide-react";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { MapLoader } from "../MapLoader";
interface MapViewPageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: MapViewPageProps): Promise<Metadata> {
const { id } = await params;
return { title: `World Map #${id}: Polaris Task Force` };
}
export default async function MapViewPage({ params }: MapViewPageProps) {
const { id } = await params;
const mapId = Number(id);
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
const map = Number.isInteger(mapId)
? await payload
.findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true })
.catch(() => null)
: null;
if (!map) notFound();
const [canEdit, canPlace] = await Promise.all([
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
]);
// Inactive maps stay reachable for editors previewing them, but are hidden
// from everyone else.
if (!map.isActive && !canEdit) notFound();
return (
<div className="relative flex min-h-[240px] flex-1 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm">
<div className="flex items-center gap-2">
<MapIcon className="size-4 text-muted-foreground" />
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">{map.name}</h1>
</div>
<div className="flex items-center gap-4 font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
<span>PTF Unit Command // Tactical Grid</span>
<span>
{map.worldSizeWidth}×{map.worldSizeHeight} m
</span>
</div>
</div>
<MapLoader map={{ id: map.id, name: map.name }} canEdit={canEdit} canPlace={canPlace} />
</div>
);
}

View file

@ -1,37 +1,44 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers } from "next/headers"; import { Fragment } from "react";
import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Map as MapIcon } from "lucide-react"; import { Map as MapIcon, MapPinIcon } from "lucide-react";
import { hasPermission } from "@/utils/access-control/hasPermission"; import type { Campaign, Faction, Map as MapDoc, Media } from "@/payload-types";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import {
import { MapLoader } from "./MapLoader"; easeStatClass,
isWorldMapEntry,
weatherStatClass,
weatherStatLabel,
worldMapScreenQuery,
} from "@/lib/map/screen";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "World Map: Polaris Task Force", title: "World Map: Polaris Task Force",
}; };
export default async function MapPage() { function basemapUrl(map: MapDoc): string | null {
const payload = await getPayload({ config: await config }); const image = map.basemapImage as Media | number | null | undefined;
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false }); if (image && typeof image === "object" && image.url) return image.url;
return null;
}
const [mapsRes, canEdit, canPlace] = await Promise.all([ export default async function MapPickerPage() {
payload.find({ const payload = await getPayload({ config: await config });
const mapsRes = await payload.find({
collection: "maps", collection: "maps",
where: { isActive: { equals: true } }, where: worldMapScreenQuery(),
sort: "name", sort: "name",
limit: 100, limit: 100,
depth: 0, depth: 1,
overrideAccess: true, overrideAccess: true,
}), });
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
]);
const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name })); const maps = mapsRes.docs.filter((map) => isWorldMapEntry(map));
return ( return (
<div className="relative flex min-h-[240px] flex-1 flex-col"> <div className="flex min-h-[240px] flex-1 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm"> <div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapIcon className="size-4 text-muted-foreground" /> <MapIcon className="size-4 text-muted-foreground" />
@ -44,7 +51,104 @@ export default async function MapPage() {
</span> </span>
</div> </div>
</div> </div>
<MapLoader maps={maps} canEdit={canEdit} canPlace={canPlace} />
{maps.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-24 text-muted-foreground">
<MapPinIcon className="size-6" />
<p className="text-sm">
No maps published to the world map yet. Maps are configured in the admin panel (World:
Maps) with the &quot;World Map Entry&quot; flag.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{maps.map((map) => {
const campaign =
map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null;
const faction =
map.factionControl && typeof map.factionControl === "object"
? (map.factionControl as Faction)
: null;
const url = basemapUrl(map);
const stats = [
map.weather
? {
key: `w-${map.id}`,
label: weatherStatLabel(map.weather),
cls: weatherStatClass(map.weather),
}
: null,
map.easeOfAccess
? {
key: `e-${map.id}`,
label: `${map.easeOfAccess} access`,
cls: easeStatClass(map.easeOfAccess),
}
: null,
faction ? { key: `f-${map.id}`, label: faction.name, cls: "text-violet-400" } : null,
].filter((stat) => stat !== null);
return (
<Link
key={map.id}
href={`/map/${map.id}`}
className="group overflow-hidden rounded-lg border border-border bg-card transition-all duration-200 hover:scale-[1.01] hover:border-primary/40"
>
<div className="relative aspect-video w-full overflow-hidden bg-muted">
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={map.name}
className="size-full object-cover opacity-80 transition-opacity group-hover:opacity-100"
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<MapIcon className="size-8 opacity-40" />
</div>
)}
</div>
<div className="border-t border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold">{map.name}</p>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{map.worldSizeWidth}×{map.worldSizeHeight} m
</span>
</div>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
{campaign ? campaign.name : "Unit grid"}
</p>
{map.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{map.description}
</p>
)}
{stats.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/60 pt-1.5">
{stats.map((stat, index) => (
<Fragment key={stat.key}>
{index > 0 && (
<span
aria-hidden="true"
className="font-mono text-[10px] text-white/25"
>
&bull;
</span>
)}
<span
className={`font-mono text-[10px] uppercase tracking-wider ${stat.cls}`}
>
{stat.label}
</span>
</Fragment>
))}
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div> </div>
); );
} }

View file

@ -202,6 +202,7 @@ export async function GET(req: NextRequest) {
description: node.description ?? null, description: node.description ?? null,
resources: relEntries(node.resources), resources: relEntries(node.resources),
richness: node.richness ?? null, richness: node.richness ?? null,
icon: node.icon ?? null,
})); }));
const roads = roadsRes.docs.map((road) => ({ const roads = roadsRes.docs.map((road) => ({

View file

@ -29,6 +29,81 @@ export const Maps: CollectionConfig = {
description: "Inactive maps are hidden from the map page and cannot receive placements.", description: "Inactive maps are hidden from the map page and cannot receive placements.",
}, },
}, },
{
name: "worldMap",
type: "checkbox",
label: "World Map Entry",
defaultValue: false,
admin: {
description:
"Show on the World Map screen. Leave off for mission-only maps (e.g. side op maps without imagery).",
},
},
{
name: "campaign",
type: "relationship",
relationTo: "campaigns",
label: "Campaign",
admin: {
description:
"Campaign this map belongs to. Maps tied to custom (side) campaigns are excluded from the World Map screen even when flagged as world map entries.",
},
},
{
name: "description",
type: "textarea",
label: "Description",
admin: {
description: "Optional map blurb shown on the World Map picker cards.",
},
},
{
name: "weather",
type: "text",
label: "General Weather",
admin: {
description:
"Optional freeform weather condition for intel display (e.g. Clear, Monsoon, Ashfall).",
},
},
{
name: "latitude",
type: "number",
label: "Real-World Latitude",
admin: {
description: "Optional real-world reference coordinate (degrees).",
},
},
{
name: "longitude",
type: "number",
label: "Real-World Longitude",
admin: {
description: "Optional real-world reference coordinate (degrees).",
},
},
{
name: "easeOfAccess",
type: "select",
label: "Ease of Access",
options: [
{ label: "Easy", value: "easy" },
{ label: "Moderate", value: "moderate" },
{ label: "Difficult", value: "difficult" },
],
admin: {
description: "Optional: how hard the area is to reach for intel display.",
},
},
{
name: "factionControl",
type: "relationship",
relationTo: "factions",
label: "Faction Control",
admin: {
description: "Optional faction associated with or controlling this map.",
},
},
{ {
label: "World Size (meters)", label: "World Size (meters)",
type: "group", type: "group",
@ -71,7 +146,8 @@ export const Maps: CollectionConfig = {
{ label: "XYZ Tile Layer", value: "tiles" }, { label: "XYZ Tile Layer", value: "tiles" },
], ],
admin: { admin: {
description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.", description:
"Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
}, },
}, },
{ {

View file

@ -1,6 +1,7 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission"; import { requirePermission } from "@/utils/access-control/hasPermission";
import { validatePosition } from "@/lib/map/points"; import { validatePosition } from "@/lib/map/points";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
/** /**
* Map resource nodes (deposits, forests, wells, ...). Display + placement * Map resource nodes (deposits, forests, wells, ...). Display + placement
@ -46,9 +47,21 @@ export const ResourceNodes: CollectionConfig = {
type: "relationship", type: "relationship",
relationTo: "resources", relationTo: "resources",
hasMany: true, hasMany: true,
required: true,
admin: { admin: {
description: "Every resource this node yields (e.g. copper AND oil).", description:
"Every resource this node yields (e.g. copper AND oil). Leave empty for a plain marker node.",
},
},
{
name: "icon",
type: "select",
options: NODE_ICON_OPTIONS.map((option) => ({
label: option.label,
value: option.value,
})),
defaultValue: "marker",
admin: {
description: "Marker icon shown on the map.",
}, },
}, },
{ {

View file

@ -155,7 +155,7 @@ export function AppSidebar({
)} )}
<NavCore label="Operations" items={data.navOperations} /> <NavCore label="Operations" items={data.navOperations} />
{hasLogistics && ( {hasLogistics && (
<NavCore label="Infrastructure" items={data.navLogistics} /> <NavCore label="Infrastructure" items={data.navLogistics} expandDefaultOpen={hasLogistics} />
)} )}
<NavCore label="Chronicle" items={data.navStory} /> <NavCore label="Chronicle" items={data.navStory} />
<NavCore label="Helpdesk" items={data.navHelpdesk} /> <NavCore label="Helpdesk" items={data.navHelpdesk} />

View file

@ -18,6 +18,7 @@ import {
export function NavCore({ export function NavCore({
label, label,
items, items,
expandDefaultOpen = true,
}: { }: {
label: string; label: string;
items: { items: {
@ -25,12 +26,14 @@ export function NavCore({
url: string; url: string;
icon: LucideIcon; icon: LucideIcon;
isActive?: boolean; isActive?: boolean;
defaultOpen?: boolean;
items?: { items?: {
title: string; title: string;
url: string; url: string;
icon?: LucideIcon; icon?: LucideIcon;
}[]; }[];
}[]; }[];
expandDefaultOpen?: boolean;
}) { }) {
if (items.length === 0) return null; if (items.length === 0) return null;
@ -44,7 +47,7 @@ export function NavCore({
<Collapsible <Collapsible
key={item.title} key={item.title}
asChild asChild
defaultOpen={item.isActive} defaultOpen={item.isActive || (item.defaultOpen === true && expandDefaultOpen !== false)}
className="group/collapsible" className="group/collapsible"
> >
<SidebarMenuItem> <SidebarMenuItem>

View file

@ -12,6 +12,7 @@ import {
Flag, Flag,
Kanban, Kanban,
LifeBuoy, LifeBuoy,
Map,
Package, Package,
Radar, Radar,
ScrollText, ScrollText,
@ -25,10 +26,13 @@ import {
} from "lucide-react"; } from "lucide-react";
export const sidebarData = { export const sidebarData = {
// World Map hidden until the feature ships; the /map route and all of its navMap: [
// functionality stay live for direct-URL access. Restore with: {
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import). title: "World Map",
navMap: [], url: "/map",
icon: Map,
},
],
navHelpdesk: [ navHelpdesk: [
{ {
title: "Helpdesk", title: "Helpdesk",
@ -90,6 +94,7 @@ export const sidebarData = {
title: "Assets", title: "Assets",
url: "", url: "",
icon: Boxes, icon: Boxes,
defaultOpen: true,
items: [ items: [
{ {
title: "Structures", title: "Structures",
@ -112,6 +117,7 @@ export const sidebarData = {
title: "Operations", title: "Operations",
url: "", url: "",
icon: Package, icon: Package,
defaultOpen: true,
items: [ items: [
{ {
title: "Shipments", title: "Shipments",
@ -134,6 +140,7 @@ export const sidebarData = {
title: "Personnel", title: "Personnel",
url: "", url: "",
icon: Users, icon: Users,
defaultOpen: true,
items: [ items: [
{ {
title: "Contacts", title: "Contacts",

View file

@ -25,6 +25,7 @@ import {
saveMapZone, saveMapZone,
} from "@/app/(frontend)/logistics/map/actions"; } from "@/app/(frontend)/logistics/map/actions";
import type { MapPoint } from "@/lib/map/points"; import type { MapPoint } from "@/lib/map/points";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types"; import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
export type AuthoringKind = "road" | "zone" | "node" | null; export type AuthoringKind = "road" | "zone" | "node" | null;
@ -80,6 +81,8 @@ export interface MapAuthoring {
setNodeDescription: (value: string) => void; setNodeDescription: (value: string) => void;
resourceIds: number[]; resourceIds: number[];
toggleResourceId: (id: number) => void; toggleResourceId: (id: number) => void;
nodeIcon: string;
setNodeIcon: (value: string) => void;
zoneResourceIds: number[]; zoneResourceIds: number[];
toggleZoneResourceId: (id: number) => void; toggleZoneResourceId: (id: number) => void;
richness: string; richness: string;
@ -123,6 +126,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [nodeName, setNodeName] = useState(""); const [nodeName, setNodeName] = useState("");
const [nodeDescription, setNodeDescription] = useState(""); const [nodeDescription, setNodeDescription] = useState("");
const [resourceIds, setResourceIds] = useState<number[]>([]); const [resourceIds, setResourceIds] = useState<number[]>([]);
const [nodeIcon, setNodeIcon] = useState("marker");
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]); const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
const [richness, setRichness] = useState("100"); const [richness, setRichness] = useState("100");
@ -212,6 +216,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setNodeName(node.name); setNodeName(node.name);
setNodeDescription(node.description ?? ""); setNodeDescription(node.description ?? "");
setResourceIds(node.resources.map((entry) => entry.id)); setResourceIds(node.resources.map((entry) => entry.id));
setNodeIcon(node.icon ?? "marker");
setRichness(String(node.richness ?? 100)); setRichness(String(node.richness ?? 100));
setEditing({ kind: "node", id: node.id, label: node.name }); setEditing({ kind: "node", id: node.id, label: node.name });
}, []); }, []);
@ -272,6 +277,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
id: editing?.kind === "node" ? editing.id : undefined, id: editing?.kind === "node" ? editing.id : undefined,
mapId, mapId,
name: nodeName.trim(), name: nodeName.trim(),
icon: nodeIcon,
description: nodeDescription.trim() || null, description: nodeDescription.trim() || null,
resourceIds, resourceIds,
position: draftPoints[0], position: draftPoints[0],
@ -330,6 +336,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
nodeName, nodeName,
nodeDescription, nodeDescription,
resourceIds, resourceIds,
nodeIcon,
zoneResourceIds, zoneResourceIds,
richness, richness,
onSaved, onSaved,
@ -337,9 +344,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const roadReady = kind !== "road" || draftPoints.length >= 2; const roadReady = kind !== "road" || draftPoints.length >= 2;
const zoneReady = kind !== "zone" || draftPoints.length >= 3; const zoneReady = kind !== "zone" || draftPoints.length >= 3;
const nodeReady = const nodeReady = kind !== "node" || (draftPoints.length === 1 && nodeName.trim().length > 0);
kind !== "node" ||
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving; const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
return { return {
@ -387,6 +392,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setNodeDescription, setNodeDescription,
resourceIds, resourceIds,
toggleResourceId, toggleResourceId,
nodeIcon,
setNodeIcon,
zoneResourceIds, zoneResourceIds,
toggleZoneResourceId, toggleZoneResourceId,
richness, richness,
@ -822,6 +829,21 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30" className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/> />
</div> </div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Icon</Label>
<Select value={fields.nodeIcon} onValueChange={fields.setNodeIcon}>
<SelectTrigger className="h-8 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NODE_ICON_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ResourcePicker <ResourcePicker
label="Resources" label="Resources"
selected={fields.resourceIds} selected={fields.resourceIds}

View file

@ -21,13 +21,6 @@ import {
import Link from "next/link"; import Link from "next/link";
import { Hammer, Pencil, RefreshCw } from "lucide-react"; import { Hammer, Pencil, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick"; import { useGameTick } from "@/hooks/useGameTick";
import { import {
distanceSquared, distanceSquared,
@ -56,9 +49,10 @@ import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/l
import { PlacementDialog } from "./PlacementDialog"; import { PlacementDialog } from "./PlacementDialog";
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar"; import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
import { StructureDetailDrawer } from "./StructureDetailDrawer"; import { StructureDetailDrawer } from "./StructureDetailDrawer";
import { nodeIconOption } from "@/lib/map/nodeIcons";
export interface MapClientProps { export interface MapClientProps {
maps: { id: number; name: string }[]; map: { id: number; name: string };
canEdit: boolean; canEdit: boolean;
canPlace: boolean; canPlace: boolean;
} }
@ -268,12 +262,23 @@ function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
} }
const nodeIcon = L.divIcon({ const nodeIconCache = new Map<string, L.DivIcon>();
function nodeIconFor(icon: string | null | undefined): L.DivIcon {
const key = icon ?? "marker";
let cached = nodeIconCache.get(key);
if (!cached) {
const option = nodeIconOption(key);
cached = L.divIcon({
className: "map-structure-icon", className: "map-structure-icon",
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(NODE_SVG_PATH, "#22d3ee")}</div>`, html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(option.path, "#22d3ee")}</div>`,
iconSize: [20, 20], iconSize: [20, 20],
iconAnchor: [10, 10], iconAnchor: [10, 10],
}); });
nodeIconCache.set(key, cached);
}
return cached;
}
const shipmentIconCache = new Map<number, L.DivIcon>(); const shipmentIconCache = new Map<number, L.DivIcon>();
@ -1145,8 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) {
); );
} }
export function MapClient({ maps, canEdit, canPlace }: MapClientProps) { export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
const [selectedMapId, setSelectedMapId] = useState<number | null>(maps[0]?.id ?? null); const selectedMapId = map.id;
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null); const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [toggles, setToggles] = useState<LayerToggles>({ const [toggles, setToggles] = useState<LayerToggles>({
@ -1304,16 +1309,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
]; ];
}, [mode, authoring.kind, features]); }, [mode, authoring.kind, features]);
if (maps.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<p className="text-sm text-muted-foreground">
No active maps yet. Maps are configured in the admin panel (World: Maps).
</p>
</div>
);
}
const toggle = (key: keyof LayerToggles) => const toggle = (key: keyof LayerToggles) =>
setToggles((current) => ({ ...current, [key]: !current[key] })); setToggles((current) => ({ ...current, [key]: !current[key] }));
@ -1519,7 +1514,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
{features && toggles.nodes && ( {features && toggles.nodes && (
<> <>
{nodeDots.map(({ node, position }) => ( {nodeDots.map(({ node, position }) => (
<Marker key={`node-${node.id}`} position={position} icon={nodeIcon}> <Marker key={`node-${node.id}`} position={position} icon={nodeIconFor(node.icon)}>
<Tooltip <Tooltip
direction="top" direction="top"
offset={[0, -14]} offset={[0, -14]}
@ -1538,13 +1533,15 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
<Popup> <Popup>
<div className="text-sm"> <div className="text-sm">
<div className="font-semibold">{node.name}</div> <div className="font-semibold">{node.name}</div>
{node.resources.length > 0 ? (
<div className="text-xs text-white/60"> <div className="text-xs text-white/60">
{node.resources.map((entry) => entry.name).join(", ") || "No resources"} Resources: {node.resources.map((entry) => entry.name).join(", ")}
</div> </div>
) : null}
{node.description && ( {node.description && (
<div className="mt-1 text-xs text-white/70">{node.description}</div> <div className="mt-1 text-xs text-white/70">{node.description}</div>
)} )}
{node.richness != null && ( {node.richness != null && node.resources.length > 0 && (
<div className="text-xs text-white/60">Richness {node.richness}</div> <div className="text-xs text-white/60">Richness {node.richness}</div>
)} )}
{canEdit && mode === "edit" && ( {canEdit && mode === "edit" && (
@ -1667,26 +1664,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
</span> </span>
</div> </div>
<div className="flex flex-wrap items-center gap-2 p-2"> <div className="flex flex-wrap items-center gap-2 p-2">
<Select
value={selectedMapId != null ? String(selectedMapId) : undefined}
onValueChange={(value) => {
authoring.setKind(null);
setPreviewId(null);
setSelectedMapId(Number(value));
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="Select map" />
</SelectTrigger>
<SelectContent>
{maps.map((map) => (
<SelectItem key={map.id} value={String(map.id)}>
{map.name}
</SelectItem>
))}
</SelectContent>
</Select>
{layerToggleKeys.map(({ key, label }) => ( {layerToggleKeys.map(({ key, label }) => (
<Button <Button
key={key} key={key}

77
src/lib/map/nodeIcons.ts Normal file
View file

@ -0,0 +1,77 @@
/**
* Pure module: the curated icon set for map node markers.
*
* Each entry carries a 24x24 stroke-path SVG body (same drawing convention as
* the map's dossier pins) so the map layer, the authoring toolbar, and the
* Payload admin select all render from one shared list. `nodeIconPath`
* falls back to the default marker for unknown or missing values, which also
* covers nodes authored before the field existed.
*/
export interface NodeIconOption {
value: string;
label: string;
path: string;
}
export const NODE_ICON_OPTIONS: readonly NodeIconOption[] = [
{
value: "marker",
label: "Marker",
path: '<path d="m8 3 4 8 5-5 5 15H2L8 3z"/>',
},
{
value: "droplet",
label: "Water",
path: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/>',
},
{
value: "trees",
label: "Forest",
path: '<path d="M8 2 4 9h3L4 15h6L8 9l3-7z"/><path d="M17 7l-3 6h2.5L14 19h6l-2.5-6H20z"/>',
},
{
value: "mountain",
label: "Mountain",
path: '<path d="m3 20 6-14 4 8 3-5 5 11z"/>',
},
{
value: "flag",
label: "Flag",
path: '<path d="M4 22V4"/><path d="M4 4h13l-2.5 4L17 12H4"/>',
},
{
value: "star",
label: "Objective",
path: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
},
{
value: "skull",
label: "Danger",
path: '<path d="M8 20v2h8v-2"/><path d="m12.5 17-.5-1-.5 1h1z"/><path d="M16 20a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/>',
},
{
value: "home",
label: "Camp",
path: '<path d="m3 11 9-8 9 8"/><path d="M5 10v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V10"/><path d="M10 21v-6h4v6"/>',
},
{
value: "anchor",
label: "Harbor",
path: '<circle cx="12" cy="5" r="3"/><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/>',
},
{
value: "fuel",
label: "Fuel",
path: '<line x1="3" y1="22" x2="15" y2="22"/><line x1="4" y1="9" x2="14" y2="9"/><path d="M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18"/><path d="M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-6.34a2 2 0 0 0-.59-1.42L19 8"/>',
},
];
const ICONS_BY_VALUE = new Map(NODE_ICON_OPTIONS.map((option) => [option.value, option]));
const DEFAULT_ICON = ICONS_BY_VALUE.get("marker")!;
export function nodeIconOption(value: string | null | undefined): NodeIconOption {
if (value && ICONS_BY_VALUE.has(value)) return ICONS_BY_VALUE.get(value)!;
return DEFAULT_ICON;
}

85
src/lib/map/screen.ts Normal file
View file

@ -0,0 +1,85 @@
import type { Where } from "payload";
/**
* Pure module: which maps are eligible for the World Map screen.
*
* A map is listed when it is an explicit world-map entry, active, and NOT tied
* to a custom (side op) campaign. Maps without a campaign still qualify, so
* the unit's standing grid does not need a campaign link to appear.
*/
export type WorldMapRow = {
id: number;
name: string;
isActive?: boolean | null;
worldMap?: boolean | null;
campaign?: (number | null) | { campaignMode?: string | null } | null;
};
export function isWorldMapEntry(map: WorldMapRow): boolean {
if (!map.worldMap || map.isActive === false) return false;
if (map.campaign && typeof map.campaign === "object") {
return map.campaign.campaignMode !== "custom";
}
return true;
}
export function worldMapScreenQuery(): Where {
return {
and: [
{ worldMap: { equals: true } },
{ isActive: { not_equals: false } },
{
or: [
{ campaign: { exists: false } },
{
and: [
{ "campaign.campaignMode": { exists: true } },
{ "campaign.campaignMode": { not_equals: "custom" } },
],
},
],
},
],
};
}
/**
* Text color class for a freeform weather value. Matches keywords in the
* lowercase value so any custom weather string gets a sensible hue; unknown
* values fall back to neutral.
*/
export function weatherStatClass(weather: string): string {
const value = weather.toLowerCase();
if (/(heat|drought|desert|sand|dust)/.test(value)) return "text-orange-400";
if (/(storm|thunder|hurricane|tornado)/.test(value)) return "text-violet-400";
if (/(rain|drizzle|shower|monsoon)/.test(value)) return "text-sky-400";
if (/(snow|blizzard|sleet|hail|ice|winter)/.test(value)) return "text-cyan-300";
if (/(fog|mist|haze|smog)/.test(value)) return "text-slate-400";
if (/(overcast|cloud|gloom)/.test(value)) return "text-slate-300";
if (/(clear|sunny|fair)/.test(value)) return "text-amber-400";
if (/(ash|smoke|fallout)/.test(value)) return "text-stone-400";
if (/(wind|gale|breeze)/.test(value)) return "text-teal-300";
return "text-muted-foreground";
}
/** Text color class for the fixed ease-of-access levels. */
export function easeStatClass(ease: string): string {
switch (ease) {
case "easy":
return "text-emerald-400";
case "moderate":
return "text-amber-400";
case "difficult":
return "text-red-400";
default:
return "text-muted-foreground";
}
}
/** Display label for a freeform weather value: trimmed, first letter capitalized. */
export function weatherStatLabel(weather: string): string {
const trimmed = weather.trim();
if (trimmed.length === 0) return "";
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
}

View file

@ -50,6 +50,7 @@ export interface NodeFeature {
description: string | null; description: string | null;
resources: { id: number; name: string }[]; resources: { id: number; name: string }[];
richness: number | null; richness: number | null;
icon: string | null;
} }
export interface RoadFeature { export interface RoadFeature {

View file

@ -0,0 +1,16 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`ALTER TABLE "maps" ADD COLUMN "world_map" boolean DEFAULT false NOT NULL;
ALTER TABLE "maps" ADD COLUMN "campaign_id" integer;
ALTER TABLE "maps" ADD CONSTRAINT "maps_campaign_id_campaigns_id_fk" FOREIGN KEY ("campaign_id") REFERENCES "public"."campaigns"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "maps_campaign_id_idx" ON "maps" USING btree ("campaign_id");
UPDATE "maps" SET "world_map" = true WHERE "is_active" = true;`);
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`DROP INDEX "maps_campaign_id_idx";
ALTER TABLE "maps" DROP CONSTRAINT IF EXISTS "maps_campaign_id_campaigns_id_fk";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "campaign_id";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "world_map";`);
}

View file

@ -0,0 +1,27 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`CREATE TYPE "public"."enum_maps_weather" AS ENUM('clear', 'overcast', 'rain', 'storm', 'fog', 'snow');
CREATE TYPE "public"."enum_maps_ease_of_access" AS ENUM('easy', 'moderate', 'difficult');
ALTER TABLE "maps" ADD COLUMN "description" text;
ALTER TABLE "maps" ADD COLUMN "weather" "public"."enum_maps_weather";
ALTER TABLE "maps" ADD COLUMN "latitude" double precision;
ALTER TABLE "maps" ADD COLUMN "longitude" double precision;
ALTER TABLE "maps" ADD COLUMN "ease_of_access" "public"."enum_maps_ease_of_access";
ALTER TABLE "maps" ADD COLUMN "faction_control_id" integer;
ALTER TABLE "maps" ADD CONSTRAINT "maps_faction_control_id_factions_id_fk" FOREIGN KEY ("faction_control_id") REFERENCES "public"."factions"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "maps_faction_control_id_idx" ON "maps" USING btree ("faction_control_id");`);
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`DROP INDEX "maps_faction_control_id_idx";
ALTER TABLE "maps" DROP CONSTRAINT IF EXISTS "maps_faction_control_id_factions_id_fk";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "faction_control_id";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "ease_of_access";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "longitude";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "latitude";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "weather";
ALTER TABLE "maps" DROP COLUMN IF EXISTS "description";
DROP TYPE IF EXISTS "public"."enum_maps_ease_of_access";
DROP TYPE IF EXISTS "public"."enum_maps_weather";`);
}

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`ALTER TABLE "maps" ALTER COLUMN "weather" TYPE text USING "weather"::text;
DROP TYPE IF EXISTS "public"."enum_maps_weather";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`CREATE TYPE "public"."enum_maps_weather" AS ENUM('clear', 'overcast', 'rain', 'storm', 'fog', 'snow');
ALTER TABLE "maps" ALTER COLUMN "weather" TYPE "public"."enum_maps_weather" USING CASE WHEN "weather" IN ('clear', 'overcast', 'rain', 'storm', 'fog', 'snow') THEN "weather"::"public"."enum_maps_weather" ELSE NULL END;`)
}

View file

@ -0,0 +1,9 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`ALTER TABLE "resource_nodes" ADD COLUMN "icon" varchar DEFAULT 'marker';`);
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`ALTER TABLE "resource_nodes" DROP COLUMN IF EXISTS "icon";`);
}

View file

@ -6544,7 +6544,9 @@ export const game_rules = pgTable(
onDelete: "set null", onDelete: "set null",
}), }),
currencyName: varchar("currency_name"), currencyName: varchar("currency_name"),
currencySymbol: varchar("currency_symbol"), currencySymbol: integer("currency_symbol_id").references(() => media.id, {
onDelete: "set null",
}),
currencySingular: varchar("currency_singular"), currencySingular: varchar("currency_singular"),
currencyPlural: varchar("currency_plural"), currencyPlural: varchar("currency_plural"),
currencyDenomination: varchar("currency_denomination"), currencyDenomination: varchar("currency_denomination"),
@ -6574,7 +6576,10 @@ export const game_rules = pgTable(
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }), updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }),
createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 }), createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 }),
}, },
(columns) => [index("game_rules_main_currency_idx").on(columns.mainCurrency)], (columns) => [
index("game_rules_main_currency_idx").on(columns.mainCurrency),
index("game_rules_currency_symbol_idx").on(columns.currencySymbol),
],
); );
export const game_rules_rels = pgTable( export const game_rules_rels = pgTable(

View file

@ -1952,6 +1952,38 @@ export interface Map {
* Inactive maps are hidden from the map page and cannot receive placements. * Inactive maps are hidden from the map page and cannot receive placements.
*/ */
isActive?: boolean | null; isActive?: boolean | null;
/**
* Show on the World Map screen. Leave off for mission-only maps (e.g. side op maps without imagery).
*/
worldMap?: boolean | null;
/**
* Campaign this map belongs to. Maps tied to custom (side) campaigns are excluded from the World Map screen even when flagged as world map entries.
*/
campaign?: (number | null) | Campaign;
/**
* Optional map blurb shown on the World Map picker cards.
*/
description?: string | null;
/**
* Optional freeform weather condition for intel display (e.g. Clear, Monsoon, Ashfall).
*/
weather?: string | null;
/**
* Optional real-world reference coordinate (degrees).
*/
latitude?: number | null;
/**
* Optional real-world reference coordinate (degrees).
*/
longitude?: number | null;
/**
* Optional: how hard the area is to reach for intel display.
*/
easeOfAccess?: ('easy' | 'moderate' | 'difficult') | null;
/**
* Optional faction associated with or controlling this map.
*/
factionControl?: (number | null) | Faction;
/** /**
* Map width in real meters (e.g. Altis is 8192). * Map width in real meters (e.g. Altis is 8192).
*/ */
@ -4043,9 +4075,13 @@ export interface ResourceNode {
description?: string | null; description?: string | null;
map: number | Map; map: number | Map;
/** /**
* Every resource this node yields (e.g. copper AND oil). * Every resource this node yields (e.g. copper AND oil). Leave empty for a plain marker node.
*/ */
resources: (number | Resource)[]; resources?: (number | Resource)[] | null;
/**
* Marker icon shown on the map.
*/
icon?: ('marker' | 'droplet' | 'trees' | 'mountain' | 'flag' | 'star' | 'skull' | 'home' | 'anchor' | 'fuel') | null;
/** /**
* [x, y] tuple in meters on the map grid. * [x, y] tuple in meters on the map grid.
*/ */
@ -8046,6 +8082,14 @@ export interface LabelsSelect<T extends boolean = true> {
export interface MapsSelect<T extends boolean = true> { export interface MapsSelect<T extends boolean = true> {
name?: T; name?: T;
isActive?: T; isActive?: T;
worldMap?: T;
campaign?: T;
description?: T;
weather?: T;
latitude?: T;
longitude?: T;
easeOfAccess?: T;
factionControl?: T;
worldSizeWidth?: T; worldSizeWidth?: T;
worldSizeHeight?: T; worldSizeHeight?: T;
basemapMode?: T; basemapMode?: T;
@ -8105,6 +8149,7 @@ export interface ResourceNodesSelect<T extends boolean = true> {
description?: T; description?: T;
map?: T; map?: T;
resources?: T; resources?: T;
icon?: T;
position?: T; position?: T;
richness?: T; richness?: T;
updatedAt?: T; updatedAt?: T;
@ -9120,9 +9165,9 @@ export interface GameRule {
*/ */
currencyName?: string | null; currencyName?: string | null;
/** /**
* Optional symbol prefixed to amounts, e.g. "¢" or "$". * Optional emblem shown inline before amounts. It renders scaled to the surrounding text height (unlike a text symbol).
*/ */
currencySymbol?: string | null; currencySymbol?: (number | null) | Media;
/** /**
* Singular amount label, e.g. "credit". * Singular amount label, e.g. "credit".
*/ */

View file

@ -0,0 +1,178 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import config from "@payload-config";
import { getPayload, type Payload } from "payload";
import type { Map as MapDoc, Resource } from "@/payload-types";
import { checkResourcesInRange } from "@/lib/map/placement";
import { nodeIconOption } from "@/lib/map/nodeIcons";
import { saveMapNode } from "@/app/(frontend)/logistics/map/actions";
vi.mock("next/headers", () => ({
headers: async () => new Headers(),
}));
describe("generic marker nodes (no resources)", () => {
let payload: Payload;
let map: MapDoc;
let copper: Resource;
let authSpy: ReturnType<typeof vi.spyOn>;
let mapper: { id: number };
beforeAll(async () => {
payload = await getPayload({ config });
map = (await payload.create({
collection: "maps",
data: {
name: `Generic Node Map ${Date.now()}`,
worldSizeWidth: 8192,
worldSizeHeight: 8192,
basemapMode: "tiles",
tileUrlTemplate: "http://localhost/{z}/{x}/{y}.png",
isActive: true,
worldMap: false,
},
overrideAccess: true,
depth: 0,
})) as unknown as MapDoc;
const mapperRole = (await payload.create({
collection: "roles",
data: {
name: `Generic Node Mapper ${Date.now()}`,
slug: `generic-mapper-${Date.now()}`,
permissions: ["maps:create"],
},
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
mapper = (await payload.create({
collection: "users",
data: {
username: `generic-node-mapper-${Date.now()}`,
discordUsername: `generic-node-mapper-${Date.now()}`,
displayName: "Generic Node Mapper",
steamId: `generic-node-${Date.now()}`,
password: "generic-node-pass",
roleDocs: [mapperRole.id],
},
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
copper = (await payload.create({
collection: "resources",
data: {
name: `Copper ${Date.now()}`,
codeName: `generic_copper_${Date.now()}`,
approvalStatus: "in_progress",
type: "physical",
baseValue: 1,
rarity: "common",
unitOfMeasure: "kg",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
},
overrideAccess: true,
depth: 0,
})) as unknown as Resource;
});
it("persists a node with an empty resource list", async () => {
const node = await payload.create({
collection: "resource-nodes",
data: {
name: "Landmark marker",
map: map.id,
resources: [],
position: [1000, 1000],
},
overrideAccess: true,
depth: 0,
});
expect(node.resources ?? []).toEqual([]);
});
it("checkResourcesInRange never matches resource-less nodes", () => {
const requirement = [{ resource: copper.id, range: 500 }];
const genericNode = { id: 1, position: [1000, 1000] as [number, number], resources: [] };
const realNode = { id: 2, position: [1200, 1000] as [number, number], resources: [copper.id] };
const withGenericOnly = checkResourcesInRange([genericNode], requirement, [1100, 1000]);
expect(withGenericOnly.allowed).toBe(false);
const withRealNode = checkResourcesInRange([genericNode, realNode], requirement, [1100, 1000]);
expect(withRealNode.allowed).toBe(true);
});
it("saveMapNode persists a chosen icon and rejects unknown ones", async () => {
authSpy = vi.spyOn(payload, "auth").mockResolvedValue({
user: mapper as never,
} as never);
try {
const flagged = await saveMapNode({
mapId: map.id,
name: "Flag marker",
resourceIds: [],
position: [3000, 3000],
icon: "flag",
});
expect(flagged.success).toBe(true);
const flaggedNode = await payload.findByID({
collection: "resource-nodes",
id: (flagged.data as { id: number }).id,
depth: 0,
overrideAccess: true,
});
expect(flaggedNode.icon).toBe("flag");
const rejected = await saveMapNode({
mapId: map.id,
name: "Evil marker",
resourceIds: [],
position: [3000, 3000],
icon: "rocket",
});
expect(rejected.success).toBe(false);
expect(rejected.error).toContain("icon");
} finally {
authSpy.mockRestore();
}
});
it("nodeIconOption falls back to the default marker", () => {
expect(nodeIconOption("flag").value).toBe("flag");
expect(nodeIconOption("nonsense").value).toBe("marker");
expect(nodeIconOption(null).value).toBe("marker");
});
it("saveMapNode accepts an empty resource list as a plain marker", async () => {
authSpy = vi.spyOn(payload, "auth").mockResolvedValue({
user: mapper as never,
} as never);
try {
const result = await saveMapNode({
mapId: map.id,
name: "Authored marker",
resourceIds: [],
position: [2000, 2000],
richness: 100,
});
expect(result.success).toBe(true);
const created = await payload.findByID({
collection: "resource-nodes",
id: (result.data as { id: number }).id,
depth: 0,
overrideAccess: true,
});
expect(created.resources ?? []).toEqual([]);
} finally {
authSpy.mockRestore();
}
});
});

View file

@ -0,0 +1,205 @@
import { describe, it, expect, beforeAll } from "vitest";
import config from "@payload-config";
import { getPayload, type Payload } from "payload";
import type { Campaign, Map as MapDoc } from "@/payload-types";
import {
easeStatClass,
isWorldMapEntry,
weatherStatClass,
weatherStatLabel,
worldMapScreenQuery,
} from "@/lib/map/screen";
const RUN = `wm${Date.now().toString(36)}`;
describe("world map screen eligibility", () => {
let payload: Payload;
let officialCampaign: Campaign;
let customCampaign: Campaign;
let createdIds: number[] = [];
const makeMap = async (data: {
name: string;
worldMap?: boolean;
isActive?: boolean;
campaignId?: number;
}): Promise<MapDoc> => {
return (await payload.create({
collection: "maps",
data: {
name: data.name,
worldSizeWidth: 2048,
worldSizeHeight: 2048,
basemapMode: "tiles",
tileUrlTemplate: "http://localhost/{z}/{x}/{y}.png",
isActive: data.isActive ?? true,
worldMap: data.worldMap ?? false,
...(data.campaignId ? { campaign: data.campaignId } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as MapDoc;
};
beforeAll(async () => {
payload = await getPayload({ config });
officialCampaign = (await payload.create({
collection: "campaigns",
data: {
name: `${RUN} Official`,
summary: "Eligibility test official campaign.",
campaignMode: "official",
status: "inProgress",
},
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
customCampaign = (await payload.create({
collection: "campaigns",
data: {
name: `${RUN} Custom`,
summary: "Eligibility test custom campaign.",
campaignMode: "custom",
status: "inProgress",
},
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
createdIds.push(
(await makeMap({ name: `${RUN} flagged`, worldMap: true })).id,
(await makeMap({ name: `${RUN} unflagged` })).id,
(
await makeMap({
name: `${RUN} flagged custom campaign`,
worldMap: true,
campaignId: customCampaign.id,
})
).id,
(
await makeMap({
name: `${RUN} flagged official campaign`,
worldMap: true,
campaignId: officialCampaign.id,
})
).id,
(await makeMap({ name: `${RUN} flagged inactive`, worldMap: true, isActive: false })).id,
);
});
it("lists only flagged, active maps without a custom campaign", async () => {
const res = await payload.find({
collection: "maps",
where: worldMapScreenQuery(),
limit: 1000,
depth: 1,
overrideAccess: true,
});
const listedIds = res.docs.map((m) => m.id);
expect(listedIds).toContain(createdIds[0]);
expect(listedIds).not.toContain(createdIds[1]);
expect(listedIds).not.toContain(createdIds[2]);
expect(listedIds).toContain(createdIds[3]);
expect(listedIds).not.toContain(createdIds[4]);
for (const doc of res.docs) {
expect(isWorldMapEntry(doc as unknown as MapDoc)).toBe(true);
}
});
it("persists the optional intel fields", async () => {
const faction = (await payload.create({
collection: "factions",
data: { name: `${RUN} Faction`, type: "minorFaction", reputation: 0, relationships: [] },
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
const map = (await payload.create({
collection: "maps",
data: {
name: `${RUN} intel fields`,
worldSizeWidth: 4096,
worldSizeHeight: 4096,
basemapMode: "tiles",
tileUrlTemplate: "http://localhost/{z}/{x}/{y}.png",
worldMap: true,
description: "A test map with every intel field.",
weather: "Ashfall",
latitude: 35.2,
longitude: 33.1,
easeOfAccess: "difficult",
factionControl: faction.id,
},
overrideAccess: true,
depth: 0,
})) as unknown as MapDoc;
const after = (await payload.findByID({
collection: "maps",
id: map.id,
depth: 1,
overrideAccess: true,
})) as unknown as MapDoc;
expect(after.description).toBe("A test map with every intel field.");
expect(after.weather).toBe("Ashfall");
expect(after.latitude).toBeCloseTo(35.2, 5);
expect(after.longitude).toBeCloseTo(33.1, 5);
expect(after.easeOfAccess).toBe("difficult");
expect(
typeof after.factionControl === "object" && after.factionControl !== null
? (after.factionControl as { id: number }).id
: after.factionControl,
).toBe(faction.id);
});
it("weather and ease stat helpers color by value and label any text", () => {
expect(weatherStatClass("Monsoon")).toBe("text-sky-400");
expect(weatherStatClass("thunderstorm")).toBe("text-violet-400");
expect(weatherStatClass("Blizzard")).toBe("text-cyan-300");
expect(weatherStatClass("Dense fog")).toBe("text-slate-400");
expect(weatherStatClass("Sunny")).toBe("text-amber-400");
expect(weatherStatClass("Sandstorm")).toBe("text-orange-400");
expect(weatherStatClass("Ashfall")).toBe("text-stone-400");
expect(weatherStatClass("Aurora")).toBe("text-muted-foreground");
expect(weatherStatLabel("ashfall")).toBe("Ashfall");
expect(weatherStatLabel(" monsoon ")).toBe("Monsoon");
expect(weatherStatLabel(" ")).toBe("");
expect(easeStatClass("easy")).toBe("text-emerald-400");
expect(easeStatClass("moderate")).toBe("text-amber-400");
expect(easeStatClass("difficult")).toBe("text-red-400");
expect(easeStatClass("other")).toBe("text-muted-foreground");
});
it("isWorldMapEntry rejects custom-campaign maps even when flagged", () => {
expect(
isWorldMapEntry({
id: 1,
name: "x",
worldMap: true,
isActive: true,
campaign: { campaignMode: "custom" },
}),
).toBe(false);
expect(
isWorldMapEntry({
id: 2,
name: "x",
worldMap: true,
isActive: true,
campaign: { campaignMode: "official" },
}),
).toBe(true);
expect(isWorldMapEntry({ id: 3, name: "x", worldMap: true, isActive: true })).toBe(true);
expect(isWorldMapEntry({ id: 4, name: "x", worldMap: false, isActive: true })).toBe(false);
expect(isWorldMapEntry({ id: 5, name: "x", worldMap: true, isActive: false })).toBe(false);
});
});