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:
parent
679a0f7879
commit
9bbe44d38d
22 changed files with 1033 additions and 101 deletions
|
|
@ -17,6 +17,8 @@ import {
|
|||
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;
|
||||
|
|
@ -281,6 +283,7 @@ export interface MapNodeInput {
|
|||
resourceIds: number[];
|
||||
position: MapPoint;
|
||||
richness?: number | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
async function requireMapPermission(
|
||||
|
|
@ -450,17 +453,23 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
|
|||
return { success: false, error: "A node requires a single [x, y] position." };
|
||||
}
|
||||
const resourceIds = [...new Set(input.resourceIds)];
|
||||
if (resourceIds.length === 0) {
|
||||
return { success: false, error: "A node requires at least one resource." };
|
||||
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." };
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
|
|
@ -469,6 +478,7 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
|
|||
resources: resourceIds,
|
||||
position: input.position,
|
||||
richness: input.richness ?? 100,
|
||||
icon: icon as ResourceNode["icon"],
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
|
|
|
|||
60
src/app/(frontend)/map/[id]/page.tsx
Normal file
60
src/app/(frontend)/map/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +1,44 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers } from "next/headers";
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
import { Map as MapIcon, MapPinIcon } from "lucide-react";
|
||||
import type { Campaign, Faction, Map as MapDoc, Media } from "@/payload-types";
|
||||
import {
|
||||
easeStatClass,
|
||||
isWorldMapEntry,
|
||||
weatherStatClass,
|
||||
weatherStatLabel,
|
||||
worldMapScreenQuery,
|
||||
} from "@/lib/map/screen";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "World Map: Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function MapPage() {
|
||||
function basemapUrl(map: MapDoc): string | null {
|
||||
const image = map.basemapImage as Media | number | null | undefined;
|
||||
if (image && typeof image === "object" && image.url) return image.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function MapPickerPage() {
|
||||
const payload = await getPayload({ config: await config });
|
||||
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
||||
|
||||
const [mapsRes, canEdit, canPlace] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "maps",
|
||||
where: { isActive: { equals: true } },
|
||||
sort: "name",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
|
||||
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
|
||||
]);
|
||||
const mapsRes = await payload.find({
|
||||
collection: "maps",
|
||||
where: worldMapScreenQuery(),
|
||||
sort: "name",
|
||||
limit: 100,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name }));
|
||||
const maps = mapsRes.docs.filter((map) => isWorldMapEntry(map));
|
||||
|
||||
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 gap-2">
|
||||
<MapIcon className="size-4 text-muted-foreground" />
|
||||
|
|
@ -44,7 +51,104 @@ export default async function MapPage() {
|
|||
</span>
|
||||
</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 "World Map Entry" 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"
|
||||
>
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`font-mono text-[10px] uppercase tracking-wider ${stat.cls}`}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export async function GET(req: NextRequest) {
|
|||
description: node.description ?? null,
|
||||
resources: relEntries(node.resources),
|
||||
richness: node.richness ?? null,
|
||||
icon: node.icon ?? null,
|
||||
}));
|
||||
|
||||
const roads = roadsRes.docs.map((road) => ({
|
||||
|
|
|
|||
|
|
@ -29,6 +29,81 @@ export const Maps: CollectionConfig = {
|
|||
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)",
|
||||
type: "group",
|
||||
|
|
@ -71,7 +146,8 @@ export const Maps: CollectionConfig = {
|
|||
{ 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.",
|
||||
description:
|
||||
"Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { validatePosition } from "@/lib/map/points";
|
||||
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
|
||||
|
||||
/**
|
||||
* Map resource nodes (deposits, forests, wells, ...). Display + placement
|
||||
|
|
@ -46,9 +47,21 @@ export const ResourceNodes: CollectionConfig = {
|
|||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
hasMany: true,
|
||||
required: true,
|
||||
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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function AppSidebar({
|
|||
)}
|
||||
<NavCore label="Operations" items={data.navOperations} />
|
||||
{hasLogistics && (
|
||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||
<NavCore label="Infrastructure" items={data.navLogistics} expandDefaultOpen={hasLogistics} />
|
||||
)}
|
||||
<NavCore label="Chronicle" items={data.navStory} />
|
||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
export function NavCore({
|
||||
label,
|
||||
items,
|
||||
expandDefaultOpen = true,
|
||||
}: {
|
||||
label: string;
|
||||
items: {
|
||||
|
|
@ -25,12 +26,14 @@ export function NavCore({
|
|||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
}[];
|
||||
}[];
|
||||
expandDefaultOpen?: boolean;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
|
|
@ -44,7 +47,7 @@ export function NavCore({
|
|||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
defaultOpen={item.isActive || (item.defaultOpen === true && expandDefaultOpen !== false)}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Flag,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Map,
|
||||
Package,
|
||||
Radar,
|
||||
ScrollText,
|
||||
|
|
@ -25,10 +26,13 @@ import {
|
|||
} from "lucide-react";
|
||||
|
||||
export const sidebarData = {
|
||||
// World Map hidden until the feature ships; the /map route and all of its
|
||||
// functionality stay live for direct-URL access. Restore with:
|
||||
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import).
|
||||
navMap: [],
|
||||
navMap: [
|
||||
{
|
||||
title: "World Map",
|
||||
url: "/map",
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
navHelpdesk: [
|
||||
{
|
||||
title: "Helpdesk",
|
||||
|
|
@ -90,6 +94,7 @@ export const sidebarData = {
|
|||
title: "Assets",
|
||||
url: "",
|
||||
icon: Boxes,
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{
|
||||
title: "Structures",
|
||||
|
|
@ -112,6 +117,7 @@ export const sidebarData = {
|
|||
title: "Operations",
|
||||
url: "",
|
||||
icon: Package,
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{
|
||||
title: "Shipments",
|
||||
|
|
@ -134,6 +140,7 @@ export const sidebarData = {
|
|||
title: "Personnel",
|
||||
url: "",
|
||||
icon: Users,
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{
|
||||
title: "Contacts",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
saveMapZone,
|
||||
} from "@/app/(frontend)/logistics/map/actions";
|
||||
import type { MapPoint } from "@/lib/map/points";
|
||||
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
|
||||
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
|
||||
|
||||
export type AuthoringKind = "road" | "zone" | "node" | null;
|
||||
|
|
@ -80,6 +81,8 @@ export interface MapAuthoring {
|
|||
setNodeDescription: (value: string) => void;
|
||||
resourceIds: number[];
|
||||
toggleResourceId: (id: number) => void;
|
||||
nodeIcon: string;
|
||||
setNodeIcon: (value: string) => void;
|
||||
zoneResourceIds: number[];
|
||||
toggleZoneResourceId: (id: number) => void;
|
||||
richness: string;
|
||||
|
|
@ -123,6 +126,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
const [nodeName, setNodeName] = useState("");
|
||||
const [nodeDescription, setNodeDescription] = useState("");
|
||||
const [resourceIds, setResourceIds] = useState<number[]>([]);
|
||||
const [nodeIcon, setNodeIcon] = useState("marker");
|
||||
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
|
||||
const [richness, setRichness] = useState("100");
|
||||
|
||||
|
|
@ -212,6 +216,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setNodeName(node.name);
|
||||
setNodeDescription(node.description ?? "");
|
||||
setResourceIds(node.resources.map((entry) => entry.id));
|
||||
setNodeIcon(node.icon ?? "marker");
|
||||
setRichness(String(node.richness ?? 100));
|
||||
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,
|
||||
mapId,
|
||||
name: nodeName.trim(),
|
||||
icon: nodeIcon,
|
||||
description: nodeDescription.trim() || null,
|
||||
resourceIds,
|
||||
position: draftPoints[0],
|
||||
|
|
@ -330,6 +336,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
nodeName,
|
||||
nodeDescription,
|
||||
resourceIds,
|
||||
nodeIcon,
|
||||
zoneResourceIds,
|
||||
richness,
|
||||
onSaved,
|
||||
|
|
@ -337,9 +344,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
|
||||
const roadReady = kind !== "road" || draftPoints.length >= 2;
|
||||
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
|
||||
const nodeReady =
|
||||
kind !== "node" ||
|
||||
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
|
||||
const nodeReady = kind !== "node" || (draftPoints.length === 1 && nodeName.trim().length > 0);
|
||||
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
|
||||
|
||||
return {
|
||||
|
|
@ -387,6 +392,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setNodeDescription,
|
||||
resourceIds,
|
||||
toggleResourceId,
|
||||
nodeIcon,
|
||||
setNodeIcon,
|
||||
zoneResourceIds,
|
||||
toggleZoneResourceId,
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
label="Resources"
|
||||
selected={fields.resourceIds}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ import {
|
|||
import Link from "next/link";
|
||||
import { Hammer, Pencil, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import {
|
||||
distanceSquared,
|
||||
|
|
@ -56,9 +49,10 @@ import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/l
|
|||
import { PlacementDialog } from "./PlacementDialog";
|
||||
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
|
||||
import { StructureDetailDrawer } from "./StructureDetailDrawer";
|
||||
import { nodeIconOption } from "@/lib/map/nodeIcons";
|
||||
|
||||
export interface MapClientProps {
|
||||
maps: { id: number; name: string }[];
|
||||
map: { id: number; name: string };
|
||||
canEdit: 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>`;
|
||||
}
|
||||
|
||||
const nodeIcon = L.divIcon({
|
||||
className: "map-structure-icon",
|
||||
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(NODE_SVG_PATH, "#22d3ee")}</div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
});
|
||||
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",
|
||||
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(option.path, "#22d3ee")}</div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
});
|
||||
nodeIconCache.set(key, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
const shipmentIconCache = new Map<number, L.DivIcon>();
|
||||
|
||||
|
|
@ -1145,8 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||
const [selectedMapId, setSelectedMapId] = useState<number | null>(maps[0]?.id ?? null);
|
||||
export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
|
||||
const selectedMapId = map.id;
|
||||
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toggles, setToggles] = useState<LayerToggles>({
|
||||
|
|
@ -1304,16 +1309,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
];
|
||||
}, [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) =>
|
||||
setToggles((current) => ({ ...current, [key]: !current[key] }));
|
||||
|
||||
|
|
@ -1519,7 +1514,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
{features && toggles.nodes && (
|
||||
<>
|
||||
{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
|
||||
direction="top"
|
||||
offset={[0, -14]}
|
||||
|
|
@ -1538,13 +1533,15 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">{node.name}</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{node.resources.map((entry) => entry.name).join(", ") || "No resources"}
|
||||
</div>
|
||||
{node.resources.length > 0 ? (
|
||||
<div className="text-xs text-white/60">
|
||||
Resources: {node.resources.map((entry) => entry.name).join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{node.description && (
|
||||
<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>
|
||||
)}
|
||||
{canEdit && mode === "edit" && (
|
||||
|
|
@ -1667,26 +1664,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
</span>
|
||||
</div>
|
||||
<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 }) => (
|
||||
<Button
|
||||
key={key}
|
||||
|
|
|
|||
77
src/lib/map/nodeIcons.ts
Normal file
77
src/lib/map/nodeIcons.ts
Normal 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
85
src/lib/map/screen.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ export interface NodeFeature {
|
|||
description: string | null;
|
||||
resources: { id: number; name: string }[];
|
||||
richness: number | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface RoadFeature {
|
||||
|
|
|
|||
16
src/migrations/20260921_120000_add_world_map_flag.ts
Normal file
16
src/migrations/20260921_120000_add_world_map_flag.ts
Normal 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";`);
|
||||
}
|
||||
27
src/migrations/20260921_123000_add_map_intel_fields.ts
Normal file
27
src/migrations/20260921_123000_add_map_intel_fields.ts
Normal 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";`);
|
||||
}
|
||||
11
src/migrations/20260921_130500_weather_freeform.ts
Normal file
11
src/migrations/20260921_130500_weather_freeform.ts
Normal 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;`)
|
||||
}
|
||||
9
src/migrations/20260921_134000_add_node_icons.ts
Normal file
9
src/migrations/20260921_134000_add_node_icons.ts
Normal 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";`);
|
||||
}
|
||||
|
|
@ -6544,7 +6544,9 @@ export const game_rules = pgTable(
|
|||
onDelete: "set null",
|
||||
}),
|
||||
currencyName: varchar("currency_name"),
|
||||
currencySymbol: varchar("currency_symbol"),
|
||||
currencySymbol: integer("currency_symbol_id").references(() => media.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
currencySingular: varchar("currency_singular"),
|
||||
currencyPlural: varchar("currency_plural"),
|
||||
currencyDenomination: varchar("currency_denomination"),
|
||||
|
|
@ -6574,7 +6576,10 @@ export const game_rules = pgTable(
|
|||
updatedAt: timestamp("updated_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(
|
||||
|
|
|
|||
|
|
@ -1952,6 +1952,38 @@ export interface Map {
|
|||
* Inactive maps are hidden from the map page and cannot receive placements.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
|
|
@ -4043,9 +4075,13 @@ export interface ResourceNode {
|
|||
description?: string | null;
|
||||
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.
|
||||
*/
|
||||
|
|
@ -8046,6 +8082,14 @@ export interface LabelsSelect<T extends boolean = true> {
|
|||
export interface MapsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
isActive?: T;
|
||||
worldMap?: T;
|
||||
campaign?: T;
|
||||
description?: T;
|
||||
weather?: T;
|
||||
latitude?: T;
|
||||
longitude?: T;
|
||||
easeOfAccess?: T;
|
||||
factionControl?: T;
|
||||
worldSizeWidth?: T;
|
||||
worldSizeHeight?: T;
|
||||
basemapMode?: T;
|
||||
|
|
@ -8105,6 +8149,7 @@ export interface ResourceNodesSelect<T extends boolean = true> {
|
|||
description?: T;
|
||||
map?: T;
|
||||
resources?: T;
|
||||
icon?: T;
|
||||
position?: T;
|
||||
richness?: T;
|
||||
updatedAt?: T;
|
||||
|
|
@ -9120,9 +9165,9 @@ export interface GameRule {
|
|||
*/
|
||||
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".
|
||||
*/
|
||||
|
|
|
|||
178
tests/int/generic-node.int.spec.ts
Normal file
178
tests/int/generic-node.int.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
205
tests/int/world-map-screen.int.spec.ts
Normal file
205
tests/int/world-map-screen.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue