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 { 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,17 +453,23 @@ 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({
|
||||||
|
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({
|
// An empty resource list makes the node a plain marker: no resource
|
||||||
collection: "resources",
|
// validation, and placement's resourcesInRange never matches it.
|
||||||
where: { id: { in: resourceIds } },
|
|
||||||
depth: 0,
|
const icon = input.icon ?? "marker";
|
||||||
limit: resourceIds.length,
|
if (!NODE_ICON_OPTIONS.some((option) => option.value === icon)) {
|
||||||
});
|
return { success: false, error: "Unknown node icon." };
|
||||||
if (resources.docs.length !== resourceIds.length) {
|
|
||||||
return { success: false, error: "One or more resources not found." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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 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 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 payload = await getPayload({ config: await config });
|
||||||
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
|
||||||
|
|
||||||
const [mapsRes, canEdit, canPlace] = await Promise.all([
|
const mapsRes = await payload.find({
|
||||||
payload.find({
|
collection: "maps",
|
||||||
collection: "maps",
|
where: worldMapScreenQuery(),
|
||||||
where: { isActive: { equals: true } },
|
sort: "name",
|
||||||
sort: "name",
|
limit: 100,
|
||||||
limit: 100,
|
depth: 1,
|
||||||
depth: 0,
|
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 "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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
className: "map-structure-icon",
|
|
||||||
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(NODE_SVG_PATH, "#22d3ee")}</div>`,
|
function nodeIconFor(icon: string | null | undefined): L.DivIcon {
|
||||||
iconSize: [20, 20],
|
const key = icon ?? "marker";
|
||||||
iconAnchor: [10, 10],
|
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>();
|
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>
|
||||||
<div className="text-xs text-white/60">
|
{node.resources.length > 0 ? (
|
||||||
{node.resources.map((entry) => entry.name).join(", ") || "No resources"}
|
<div className="text-xs text-white/60">
|
||||||
</div>
|
Resources: {node.resources.map((entry) => entry.name).join(", ")}
|
||||||
|
</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
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;
|
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 {
|
||||||
|
|
|
||||||
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",
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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".
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
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