1
0
Fork 0

Compare commits

..

No commits in common. "308a522764306a2dae9fc9b949ecd3dd06f9b5fc" and "9ca85fce9b2692d8ca283cfc51f64405b7f4be61" have entirely different histories.

39 changed files with 816 additions and 1969 deletions

View file

@ -198,13 +198,13 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border (territory/border are informational political boundaries that never gate placement; default fills amber 0.1 / violet 0.06), fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible, `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land, fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
- **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees).
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`).
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts); `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
- **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters.
- **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`.

View file

@ -100,14 +100,10 @@ stays display-only in the placement dialog (no research system exists).
- **New road**: click vertices along the route, at least 2; set name,
description, surface (paved/dirt/trail), speed multiplier, and styling
(stroke color hex, width 1-10, dash style, name-label select: Auto/Shown/
Hidden, where Auto labels paved roads always and dirt/trail roads only at
close zoom, 4 m/px or finer); Save.
(stroke color hex, width 1-10, dash style, name-label toggle); Save.
- **New zone**: click at least 3 vertices; set name, description, type
(water/land/territory/border), and styling (fill color, edge color, fill
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
Border are informational political boundaries (they never gate placement;
only water does) and default to amber 0.1 / violet 0.06 fill.
(water/land), and styling (fill color, edge color, fill opacity 0-1, edge
width, name-label toggle); Save.
- **New node**: click once to set the position; set name, description,
resource, richness; Save.
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
@ -116,20 +112,6 @@ stays display-only in the placement dialog (no research system exists).
the fields, then "Save changes" (requires `maps:update`). Renaming, retyping,
restyling, and description edits work the same way; the feature's map cannot
be changed.
- **Vertex editing**: while a road/zone/node draft is active, every draft
vertex renders as a draggable square handle: drag to move it (drag-end
snaps), right-click to delete it (roads keep 2+ points, zones 3+), and the
small cyan square at the middle of each segment inserts a vertex there when
clicked.
- **Snapping**: while authoring, clicks and dragged vertices snap to existing
geometry within 12 screen px: feature vertices (roads/zones/nodes/
structures, including the draft's own other vertices for closing polygons)
and edges (nearest point on any road segment or zone side, closing segment
included) so a road can run exactly along a zone border or two zones can
share an edge without gaps or overlap. Vertices beat edges near corners (an
edge must be at least twice as close as the nearest vertex to win); a
moving square preview shows where the click will land (emerald = vertex,
cyan = edge).
### Labels and styling on the live map

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.41",
"version": "0.2.40",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -240,7 +240,7 @@ export interface MapZoneInput {
mapId: number;
name?: string | null;
description?: string | null;
type: "water" | "land" | "territory" | "border";
type: "water" | "land";
resourceIds?: number[];
fillColor?: string | null;
strokeColor?: string | null;

View file

@ -3253,130 +3253,6 @@
border-radius: 1.5px;
}
.map-structure-pin.map-pin-md {
width: 32px;
height: 32px;
}
.map-structure-pin.map-pin-lg {
width: 38px;
height: 38px;
}
.map-structure-ribbon {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1px;
padding: 2px 8px 3px;
border: 1px solid rgb(255 255 255 / 15%);
border-left-width: 2px;
border-radius: 1px;
background: rgb(8 10 14 / 88%);
box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
white-space: nowrap;
}
.map-structure-ribbon .ribbon-name {
font-size: 11px;
font-weight: 600;
color: rgb(255 255 255 / 95%);
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
}
.map-structure-ribbon .ribbon-meta {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 65%);
}
.map-tooltip-dossier .map-tooltip-name {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: rgb(255 255 255 / 95%);
}
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads
in the MapClient chunk after styles.css) with a white background. */
.map-host .leaflet-container .leaflet-control-attribution {
background: rgb(8 10 14 / 92%);
color: rgb(255 255 255 / 55%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 0;
}
.leaflet-container .leaflet-control-attribution a {
color: rgb(148 163 184 / 85%);
}
.map-tooltip-dossier .map-tooltip-meta {
margin-top: 1px;
font-size: 9px;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 60%);
}
/* A hovered structure (pin or ribbon) jumps above every other marker. The
!important is required to beat Leaflet's inline z-index management. */
.map-structure-icon.leaflet-marker-icon:hover {
z-index: 1200 !important;
}
.map-vertex-handle {
width: 12px;
height: 12px;
border: 1.5px solid #f8fafc;
border-radius: 2px;
background: rgb(8 10 14 / 75%);
box-shadow: 0 0 0 1px rgb(0 0 0 / 60%);
}
.map-midpoint-handle {
width: 8px;
height: 8px;
border: 1.5px solid #22d3ee;
border-radius: 1px;
background: rgb(8 10 14 / 40%);
opacity: 0.85;
}
.map-snap-preview {
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 2px;
border: 1.5px solid;
pointer-events: none;
}
.map-snap-vertex {
border-color: #34d399;
background: rgb(52 211 153 / 25%);
}
.map-snap-edge {
border-color: #22d3ee;
background: rgb(34 211 238 / 25%);
}
.map-structure-progress {
position: absolute;
left: 3px;
@ -3521,29 +3397,6 @@
overflow: visible;
}
.map-road-label {
color: rgb(255 255 255 / 94%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
white-space: nowrap;
/* Size to the text itself: inside the 0x0 divIcon container a block div
resolves to 0 width, which makes the percentage centering transform a
no-op and leaves the label hanging off the road. */
width: max-content;
background: rgb(8 10 14 / 62%);
padding: 1px 5px;
border-radius: 1px;
text-shadow:
0 0 3px rgb(0 0 0 / 95%),
0 0 7px rgb(0 0 0 / 90%),
0 0 2px rgb(0 0 0 / 90%);
pointer-events: none;
}
.map-road-label-path {
fill: none;
stroke: none;

View file

@ -96,10 +96,10 @@ export const MapRoads: CollectionConfig = {
{
name: "labelVisible",
type: "checkbox",
defaultValue: true,
label: "Show Name Label",
admin: {
description:
"Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.",
description: "Renders the road's name along the line at zoom-dependent intervals.",
},
},
{

View file

@ -48,12 +48,10 @@ export const MapZones: CollectionConfig = {
options: [
{ label: "Water", value: "water" },
{ label: "Land", value: "land" },
{ label: "Territory", value: "territory" },
{ label: "Country Border", value: "border" },
],
admin: {
description:
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.",
},
},
{
@ -78,8 +76,7 @@ export const MapZones: CollectionConfig = {
min: 0,
max: 1,
admin: {
description:
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).",
},
},
{
@ -117,8 +114,7 @@ export const MapZones: CollectionConfig = {
required: true,
validate: validatePolygon,
admin: {
description:
"Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
},
},
],

View file

@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -17,14 +16,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
@ -207,20 +199,22 @@ export function RequestTransferDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-source">Current assignment</Label>
<Combobox
id="transfer-source"
value={sourceId}
onValueChange={setSourceId}
disabled={pending}
placeholder="Select current assignment"
searchPlaceholder="Search assignments..."
options={requestableAssignments.map((assignment) => ({
value: String(assignment.id),
label: assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name,
}))}
/>
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
<SelectTrigger id="transfer-source">
<SelectValue placeholder="Select current assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requestableAssignments.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
@ -228,20 +222,26 @@ export function RequestTransferDialog({
{loadingDestinations ? (
<Skeleton className="h-9 w-full" />
) : destinations.length > 0 ? (
<Combobox
id="transfer-destination"
<Select
value={destinationId}
onValueChange={setDestinationId}
disabled={pending}
placeholder="Select destination assignment"
searchPlaceholder="Search assignments..."
options={destinations.map((assignment) => ({
value: String(assignment.id),
label: assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name,
}))}
/>
>
<SelectTrigger id="transfer-destination">
<SelectValue placeholder="Select destination assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{destinations.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Alert>
<AlertDescription>
@ -271,18 +271,10 @@ export function RequestTransferDialog({
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setInternalOpen(false)}
disabled={pending}
>
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
Cancel
</Button>
<Button
type="submit"
disabled={pending || loadingDestinations || destinations.length === 0}
>
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : "Submit request"}
</Button>

View file

@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@ -165,17 +164,18 @@ export function BankTransactionDialog({
{mode === "transfer" && (
<div className="flex flex-col gap-1.5">
<Label>Destination account</Label>
<Combobox
value={toId}
onValueChange={setToId}
placeholder="Select account"
searchPlaceholder="Search accounts..."
emptyMessage="No other accounts."
options={otherAccounts.map((a) => ({
value: String(a.id),
label: a.name,
}))}
/>
<Select value={toId} onValueChange={setToId}>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
{otherAccounts.map((a) => (
<SelectItem key={a.id} value={String(a.id)}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex flex-col gap-1.5">

View file

@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import {
Select,
@ -118,16 +117,18 @@ export function CreateAccountDialog({
{accountType === "faction" && (
<div className="flex flex-col gap-1.5">
<Label>Owner faction</Label>
<Combobox
value={ownerFactionId}
onValueChange={setOwnerFactionId}
placeholder="Select faction"
searchPlaceholder="Search factions..."
options={factions.map((f) => ({
value: String(f.id),
label: f.name,
}))}
/>
<Select value={ownerFactionId} onValueChange={setOwnerFactionId}>
<SelectTrigger>
<SelectValue placeholder="Select faction" />
</SelectTrigger>
<SelectContent>
{factions.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{accountType === "personal" && !isManager && (

View file

@ -15,8 +15,15 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
@ -346,51 +353,61 @@ export function HireStaffDialog({
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
</p>
) : (
<Combobox
value={npcId}
onValueChange={setNpcId}
placeholder="Select an NPC"
searchPlaceholder="Search NPCs..."
emptyMessage="No candidates available."
options={npcs.map((npc) => ({
value: String(npc.id),
label: npc.name,
}))}
/>
<Select value={npcId} onValueChange={setNpcId}>
<SelectTrigger>
<SelectValue placeholder="Select an NPC" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{npcs.map((npc) => (
<SelectItem key={npc.id} value={String(npc.id)}>
{npc.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
<Label>Structure</Label>
<Combobox
value={structureId}
onValueChange={handleStructureChange}
placeholder="Select a structure"
searchPlaceholder="Search structures..."
options={structures.map((structure) => ({
value: String(structure.id),
label: structure.name,
}))}
/>
<Select value={structureId} onValueChange={handleStructureChange}>
<SelectTrigger>
<SelectValue placeholder="Select a structure" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{structures.map((structure) => (
<SelectItem key={structure.id} value={String(structure.id)}>
{structure.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Position type</Label>
<Combobox
value={positionType}
onValueChange={handlePositionTypeChange}
searchPlaceholder="Search positions..."
options={[
...(fixedNpcId == null && approvedClassifications.length > 0
? [{ value: "labor", label: "Labor" }]
: []),
...approvedPositionTypes.map((position) => ({
value: position.value,
label: position.label,
})),
]}
/>
<Select value={positionType} onValueChange={handlePositionTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{fixedNpcId == null && approvedClassifications.length > 0 && (
<SelectItem value="labor">Labor</SelectItem>
)}
{approvedPositionTypes.map((position) => (
<SelectItem key={position.value} value={position.value}>
{position.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedStructure &&
approvedClassifications.length === 0 &&
approvedPositionTypes.length === 0 && (
@ -404,15 +421,20 @@ export function HireStaffDialog({
<>
<div className="flex flex-col gap-1.5">
<Label>Classification</Label>
<Combobox
value={laborClassification}
onValueChange={handleClassificationChange}
searchPlaceholder="Search classifications..."
options={approvedClassifications.map((classification) => ({
value: String(classification.id),
label: classification.name,
}))}
/>
<Select value={laborClassification} onValueChange={handleClassificationChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{approvedClassifications.map((classification) => (
<SelectItem key={classification.id} value={String(classification.id)}>
{classification.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedStructure && approvedClassifications.length === 0 && (
<p className="text-sm text-muted-foreground">
This structure has no approved labor positions.

View file

@ -4,7 +4,6 @@ import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -306,20 +305,19 @@ export default function EventLogTable() {
</DropdownMenuContent>
</DropdownMenu>
<Combobox
className="h-8 w-44"
value={targetCollection}
onValueChange={setTargetCollection}
placeholder="Target"
searchPlaceholder="Search targets..."
options={[
{ value: "all", label: "All targets" },
...TARGET_COLLECTION_OPTIONS.map(([value, label]) => ({
value,
label,
})),
]}
/>
<Select value={targetCollection} onValueChange={setTargetCollection}>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="all">All targets</SelectItem>
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36">
@ -444,7 +442,10 @@ export default function EventLogTable() {
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span>
<div className="flex items-center gap-2">
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
<Select
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>

View file

@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import type { Ticket, User } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import {
@ -265,21 +264,23 @@ export function TicketDetail({
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-assignee">Assignee</Label>
<div className="flex gap-2">
<Combobox
id="ticket-assignee"
className="min-w-0 flex-1"
<Select
value={assignee}
onValueChange={setAssignee}
disabled={controlBusy !== null}
searchPlaceholder="Search members..."
options={[
{ value: "unassigned", label: "Unassigned" },
...assigneeOptions.map((option) => ({
value: String(option.id),
label: option.label,
})),
]}
/>
>
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{assigneeOptions.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={() => void applyControl("assignee")}

View file

@ -10,7 +10,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -22,7 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
createFridaySideOp,
updateFridaySideOp,
@ -235,9 +238,7 @@ export function CreateSideOpDialog({
}
function modsInput() {
return requiredModsId === "none"
? { requiredModsId: null }
: { requiredModsId: Number(requiredModsId) };
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
}
async function handleModlistUpload(file: File) {
@ -343,8 +344,7 @@ export function CreateSideOpDialog({
estimatedDuration: Number(estimatedDuration) || 240,
maxPlayers: Number(maxPlayers) || 32,
...modsInput(),
campaignId:
campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
noCampaign: campaignChoice === "none",
...narrativeInput(),
@ -430,20 +430,25 @@ export function CreateSideOpDialog({
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Map</Label>
<Combobox
className="w-full"
<Select
value={mapChoice}
onValueChange={(v) => {
setMapChoice(v);
if (v !== "custom" && v !== "") setMapId(v);
}}
placeholder="Choose a map"
searchPlaceholder="Search maps..."
options={[
...maps.map((m) => ({ value: String(m.id), label: m.name })),
{ value: "custom", label: "+ Custom map…" },
]}
/>
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a map" />
</SelectTrigger>
<SelectContent>
{maps.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.name}
</SelectItem>
))}
<SelectItem value="custom">+ Custom map…</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>Mission type</Label>
@ -485,22 +490,20 @@ export function CreateSideOpDialog({
</div>
<div className="grid gap-1.5">
<Label>Campaign</Label>
<Combobox
className="w-full"
value={campaignChoice}
onValueChange={setCampaignChoice}
placeholder="Choose a campaign"
searchPlaceholder="Search campaigns..."
options={[
{ value: "none", label: "No campaign (one-off op)" },
...campaigns.map((c) => ({
value: String(c.id),
label: c.name,
disabled: c.disabled,
})),
{ value: "new", label: "+ New campaign…" },
]}
/>
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a campaign" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No campaign (one-off op)</SelectItem>
{campaigns.map((c) => (
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
{c.name}
</SelectItem>
))}
<SelectItem value="new">+ New campaign…</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{mapChoice === "custom" && (
@ -528,17 +531,19 @@ export function CreateSideOpDialog({
<div className="grid gap-1.5">
<Label>Mod list</Label>
<Combobox
className="w-full"
value={requiredModsId}
onValueChange={setRequiredModsId}
placeholder="No mod list (vanilla)"
searchPlaceholder="Search mod lists..."
options={[
{ value: "none", label: "No mod list (vanilla)" },
...modLists.map((l) => ({ value: String(l.id), label: l.name })),
]}
/>
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="No mod list (vanilla)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No mod list (vanilla)</SelectItem>
{modLists.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="file"
accept=".html,.htm,.txt,text/html,text/plain"
@ -550,8 +555,8 @@ export function CreateSideOpDialog({
}}
/>
<p className="text-xs text-muted-foreground">
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod list,
or pick an existing one.
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod
list, or pick an existing one.
</p>
</div>

View file

@ -5,9 +5,15 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@ -80,17 +86,18 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Asset</Label>
<Combobox
value={assetId}
onValueChange={setAssetId}
placeholder="Select an asset"
searchPlaceholder="Search assets..."
emptyMessage="No assets in your locker."
options={assets.map((a) => ({
value: String(a.id),
label: `${a.name} · ${getAssetTypeLabel(a.assetType)}`,
}))}
/>
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger>
<SelectValue placeholder="Select an asset" />
</SelectTrigger>
<SelectContent>
{assets.map((a) => (
<SelectItem key={a.id} value={String(a.id)}>
{a.name} · {getAssetTypeLabel(a.assetType)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Quantity</Label>

View file

@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -15,6 +14,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getAssetTypeLabel,
getAttachmentOptionsForItem,
@ -196,24 +202,30 @@ export function EquipmentEditorDialog({
className="grid grid-cols-[100px_1fr] items-center gap-2"
>
<span className="text-xs text-muted-foreground">{category.label}</span>
<Combobox
<Select
value={installed ? String(installed.id) : "none"}
onValueChange={(v) => handleCategoryPick(category, v)}
disabled={busy}
placeholder={
categoryOptions.length === 0
? "No compatible items"
: "Choose an attachment"
}
searchPlaceholder="Search attachments..."
options={[
{ value: "none", label: "None" },
...categoryOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}`,
})),
]}
/>
>
<SelectTrigger>
<SelectValue
placeholder={
categoryOptions.length === 0
? "No compatible items"
: "Choose an attachment"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{categoryOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
@ -228,22 +240,28 @@ export function EquipmentEditorDialog({
<Label className="text-sm font-semibold">Skin</Label>
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">Applied</span>
<Combobox
<Select
value={installedSkin ? String(installedSkin.id) : "none"}
onValueChange={handleSkinPick}
disabled={busy}
placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
}
searchPlaceholder="Search skins..."
options={[
{ value: "none", label: "None" },
...mergedSkinOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}`,
})),
]}
/>
>
<SelectTrigger>
<SelectValue
placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{mergedSkinOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{installedSkin && (
<p className="text-xs text-muted-foreground">

View file

@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@ -161,21 +160,24 @@ export function LoadoutEditor({
return (
<div key={slot.name} className="flex flex-col gap-1.5">
<Label>{slot.label}</Label>
<Combobox
<Select
value={slots[slot.name] ?? ""}
onValueChange={(v) =>
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
}
placeholder={slot.description}
searchPlaceholder="Search items..."
options={[
{ value: "none", label: "Empty" },
...options.map((item) => ({
value: String(item.assetId),
label: item.asset.name,
})),
]}
/>
>
<SelectTrigger>
<SelectValue placeholder={slot.description} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Empty</SelectItem>
{options.map((item) => (
<SelectItem key={item.assetId} value={String(item.assetId)}>
{item.asset.name}
</SelectItem>
))}
</SelectContent>
</Select>
{options.length === 0 && (
<span className="text-xs text-muted-foreground">
No matching assets in your locker.

View file

@ -4,7 +4,13 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
import {
getLoadoutSlot,
@ -71,18 +77,22 @@ export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelPr
</div>
<div className="border-b border-border/50 p-2">
<Combobox
className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"
<Select
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={changeLoadout}
disabled={busy || loadouts.length === 0}
placeholder="SELECT LOADOUT"
searchPlaceholder="Search loadouts..."
options={loadouts.map((loadout) => ({
value: String(loadout.id),
label: loadout.name,
}))}
/>
>
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
<SelectValue placeholder="SELECT LOADOUT" />
</SelectTrigger>
<SelectContent>
{loadouts.map((loadout) => (
<SelectItem key={loadout.id} value={String(loadout.id)}>
{loadout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="p-2">

View file

@ -4,8 +4,14 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import {
@ -109,20 +115,26 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Combobox
className="flex-1"
<Select
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={(v) => handleEquip(Number(v))}
disabled={busy}
placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
searchPlaceholder="Search loadouts..."
options={loadouts.map((l) => ({
value: String(l.id),
label: l.name,
}))}
/>
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
/>
</SelectTrigger>
<SelectContent>
{loadouts.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
{equippedLoadout && (
<Button
variant="outline"

View file

@ -56,16 +56,14 @@ export interface MapAuthoring {
setStrokeWeight: (value: string) => void;
strokeStyle: "solid" | "dashed" | "dotted";
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
roadLabelVisible: boolean | null;
setRoadLabelVisible: (value: boolean | null) => void;
labelVisible: boolean;
setLabelVisible: (value: boolean) => void;
zoneName: string;
setZoneName: (value: string) => void;
zoneDescription: string;
setZoneDescription: (value: string) => void;
zoneType: "water" | "land" | "territory" | "border";
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
zoneLabelVisible: boolean;
setZoneLabelVisible: (value: boolean) => void;
zoneType: "water" | "land";
setZoneType: (value: "water" | "land") => void;
fillColor: string;
setFillColor: (value: string) => void;
zoneStrokeColor: string;
@ -87,9 +85,6 @@ export interface MapAuthoring {
};
setKind: (kind: AuthoringKind) => void;
pushPoint: (point: MapPoint) => void;
updateDraftPoint: (index: number, point: MapPoint) => void;
insertDraftPoint: (index: number, point: MapPoint) => void;
removeDraftPoint: (index: number) => void;
clearDraft: () => void;
save: () => Promise<void>;
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
@ -109,11 +104,10 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [strokeColor, setStrokeColor] = useState("");
const [strokeWeight, setStrokeWeight] = useState("");
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
const [labelVisible, setLabelVisible] = useState(true);
const [zoneName, setZoneName] = useState("");
const [zoneDescription, setZoneDescription] = useState("");
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
const [zoneType, setZoneType] = useState<"water" | "land">("water");
const [fillColor, setFillColor] = useState("");
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
const [fillOpacity, setFillOpacity] = useState("");
@ -142,31 +136,6 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setDraftPoints((current) => [...current, point]);
}, []);
const updateDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => current.map((existing, i) => (i === index ? point : existing)));
}, []);
const insertDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => {
if (index < 0 || index > current.length) return current;
return [...current.slice(0, index), point, ...current.slice(index)];
});
}, []);
const removeDraftPoint = useCallback(
(index: number) => {
setDraftPoints((current) => {
const minimum = kind === "zone" ? 3 : kind === "road" ? 2 : 1;
if (current.length <= minimum) {
toast.error(`A ${kind ?? "feature"} needs at least ${minimum} points.`);
return current;
}
return current.filter((_, i) => i !== index);
});
},
[kind],
);
const clearDraft = useCallback(() => setDraftPoints([]), []);
const setKind = useCallback((next: AuthoringKind) => {
@ -185,7 +154,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeColor(road.strokeColor ?? "");
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
setStrokeStyle(road.strokeStyle ?? "solid");
setRoadLabelVisible(road.labelVisible);
setLabelVisible(road.labelVisible !== false);
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
}, []);
@ -200,7 +169,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor(zone.strokeColor ?? "");
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
setZoneLabelVisible(zone.labelVisible !== false);
setLabelVisible(zone.labelVisible !== false);
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
}, []);
@ -243,7 +212,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: strokeColor.trim() || null,
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
strokeStyle,
labelVisible: roadLabelVisible,
labelVisible,
points: draftPoints,
});
if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -260,7 +229,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: zoneStrokeColor.trim() || null,
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
labelVisible: zoneLabelVisible,
labelVisible,
points: draftPoints,
});
if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -296,8 +265,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor("");
setFillOpacity("");
setZoneStrokeWeight("");
setRoadLabelVisible(null);
setZoneLabelVisible(true);
setLabelVisible(true);
onSaved();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Save failed.");
@ -315,12 +283,11 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor,
strokeWeight,
strokeStyle,
roadLabelVisible,
labelVisible,
draftPoints,
zoneName,
zoneDescription,
zoneType,
zoneLabelVisible,
fillColor,
zoneStrokeColor,
fillOpacity,
@ -361,16 +328,14 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeWeight,
strokeStyle,
setStrokeStyle,
roadLabelVisible,
setRoadLabelVisible,
labelVisible,
setLabelVisible,
zoneName,
setZoneName,
zoneDescription,
setZoneDescription,
zoneType,
setZoneType,
zoneLabelVisible,
setZoneLabelVisible,
fillColor,
setFillColor,
zoneStrokeColor,
@ -392,9 +357,6 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
},
setKind,
pushPoint,
updateDraftPoint,
insertDraftPoint,
removeDraftPoint,
clearDraft,
save,
removeFeature,
@ -529,11 +491,6 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
</span>
{kind != null && (
<span className="text-[10px] uppercase tracking-wide text-white/40">
drag squares to move, right-click to delete, cyan squares insert
</span>
)}
<Button
size="sm"
variant="ghost"
@ -664,32 +621,14 @@ 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">Label</Label>
<Select
value={
fields.roadLabelVisible == null
? "auto"
: fields.roadLabelVisible
? "shown"
: "hidden"
}
onValueChange={(value) =>
fields.setRoadLabelVisible(
value === "shown" ? true : value === "hidden" ? false : null,
)
}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="shown">Shown</SelectItem>
<SelectItem value="hidden">Hidden</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input
type="checkbox"
checked={fields.labelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)}
/>
Label
</label>
</div>
</>
)}
@ -710,9 +649,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<Label className="text-[10px] uppercase text-white/60">Type</Label>
<Select
value={fields.zoneType}
onValueChange={(value) =>
fields.setZoneType(value as "water" | "land" | "territory" | "border")
}
onValueChange={(value) => fields.setZoneType(value as "water" | "land")}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
@ -720,8 +657,6 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<SelectContent>
<SelectItem value="water">Water</SelectItem>
<SelectItem value="land">Land</SelectItem>
<SelectItem value="territory">Territory</SelectItem>
<SelectItem value="border">Country Border</SelectItem>
</SelectContent>
</Select>
</div>
@ -771,8 +706,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input
type="checkbox"
checked={fields.zoneLabelVisible}
onChange={(event) => fields.setZoneLabelVisible(event.target.checked)}
checked={fields.labelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)}
/>
Label
</label>

View file

@ -30,20 +30,11 @@ import {
} from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick";
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
import {
findSnap,
segmentMidpoints,
type SnapKind,
type SnapMatch,
type SnapTarget,
} from "@/lib/map/snapping";
import {
dashArrayFor,
labelScreenAngle,
metersPerPx,
polygonCentroid,
roadLabelPoints,
roadLabelShown,
splinePolyline,
} from "@/lib/map/style";
import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types";
@ -75,11 +66,9 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
// Layer styling is hoisted to module constants: react-leaflet re-applies
// pathOptions by reference, and a fresh object literal every render forces a
// canvas redraw that glitches mid-zoom-animation.
const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
const ZONE_PATH_OPTIONS: Record<"water" | "land", L.PathOptions> = {
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
};
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
@ -122,22 +111,11 @@ const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
weight: 1,
};
type StructurePinTier = "sm" | "md" | "lg";
const STRUCTURE_PIN_SIZES: Record<StructurePinTier, number> = { sm: 26, md: 32, lg: 38 };
const STRUCTURE_PIN_SVG_SIZES: Record<StructurePinTier, number> = { sm: 13, md: 16, lg: 20 };
const ROAD_LABEL_SPACING_PX = 400;
const ROAD_LABEL_MIN_SCREEN_PX = 120;
const ROAD_LABEL_CURVE_MIN_PX = 600;
const ROAD_LABELS_MAX = 12;
const ROAD_LABEL_CHAR_PX = 9;
const STRUCTURE_LABEL_CLUSTER_PX = 56;
const STRUCTURE_PIN_MID_M_PER_PX = 8;
const STRUCTURE_RIBBON_M_PER_PX = 4;
const RIBBON_HEIGHT_PX = 32;
const RIBBON_GAP_PX = 6;
const RIBBON_PIN_HALF_PX = STRUCTURE_PIN_SIZES.lg / 2;
function escapeHtml(text: string): string {
return text
@ -169,24 +147,6 @@ function polylinePathD(points: MapPoint[]): string {
.join(" ");
}
const straightLabelIconCache = new Map<string, L.DivIcon>();
function straightLabelIcon(name: string, angle: number): L.DivIcon {
const bucket = Math.round(angle / 5) * 5;
const cacheKey = `${name}:${bucket}`;
let icon = straightLabelIconCache.get(cacheKey);
if (!icon) {
icon = L.divIcon({
className: "map-road-label-icon",
html: `<div class="map-road-label" style="transform: translate(-50%, -50%) rotate(${bucket}deg)">${escapeHtml(name.toUpperCase())}</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
straightLabelIconCache.set(cacheKey, icon);
}
return icon;
}
/**
* Catmull-Rom spline through the points, emitted as cubic Beziers, so label
* glyphs turn gently through bends instead of snapping at each kink. The
@ -253,9 +213,9 @@ const STRUCTURE_SVG_PATHS: Record<string, string> = {
'<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>',
};
function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string {
function dossierSvg(paths: string, color: string, bearingDeg?: number): string {
const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : "";
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="13" height="13" 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({
@ -288,41 +248,23 @@ function shipmentIcon(bearing: number): L.DivIcon {
const structureIconCache = new Map<string, L.DivIcon>();
interface StructureRibbon {
tier: StructurePinTier;
name: string;
meta: string;
}
function getStructureIcon(
statusKey: string,
progressPct: number | null,
ribbon: StructureRibbon | null,
): L.DivIcon {
const tier = ribbon?.tier ?? "sm";
const pinSize = STRUCTURE_PIN_SIZES[tier];
const cacheKey = `${statusKey}:${progressPct ?? "full"}:${tier}:${ribbon ? `${ribbon.name}|${ribbon.meta}` : ""}`;
function getStructureIcon(statusKey: string, progressPct: number | null): L.DivIcon {
const cacheKey = `${statusKey}:${progressPct ?? "full"}`;
let icon = structureIconCache.get(cacheKey);
if (!icon) {
if (structureIconCache.size > 400) structureIconCache.clear();
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
const bar =
progressPct != null
? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>`
: "";
const ribbonHtml = ribbon
? `<div class="map-structure-ribbon" style="border-left-color:${meta.color}"><div class="ribbon-name">${escapeHtml(ribbon.name)}</div><div class="ribbon-meta">${escapeHtml(ribbon.meta)}</div></div>`
: "";
icon = L.divIcon({
className: "map-structure-icon",
html: `<div class="map-structure-pin ${tier === "sm" ? "" : `map-pin-${tier}`}" style="border-color:${meta.color}">${dossierSvg(
html: `<div class="map-structure-pin" style="border-color:${meta.color}">${dossierSvg(
STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete,
meta.color,
undefined,
STRUCTURE_PIN_SVG_SIZES[tier],
)}${bar}${ribbonHtml}</div>`,
iconSize: [pinSize, pinSize],
iconAnchor: [pinSize / 2, pinSize / 2],
)}${bar}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
});
structureIconCache.set(cacheKey, icon);
}
@ -415,159 +357,6 @@ function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
return null;
}
/** Rewrites the bottom-right attribution prefix to credit the unit and the game. */
function MapAttribution({ prefix }: { prefix: string }) {
const map = useMap();
useEffect(() => {
map.attributionControl.setPrefix(prefix);
}, [map, prefix]);
return null;
}
const SNAP_RADIUS_PX = 12;
const snapPreviewIconCache = new Map<string, L.DivIcon>();
function snapPreviewIcon(kind: SnapKind): L.DivIcon {
let icon = snapPreviewIconCache.get(kind);
if (!icon) {
icon = L.divIcon({
className: "map-snap-icon",
html: `<div class="map-snap-preview map-snap-${kind}"></div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
snapPreviewIconCache.set(kind, icon);
}
return icon;
}
/**
* Authoring click layer: while a road/zone/node draft is active, every map
* click snaps to existing geometry (feature vertices, zone/road edges,
* structure/node positions, already-placed draft vertices) before landing,
* and a moving cursor shows where the next click would land.
*/
function AuthoringSnapLayer({
targets,
worldSize,
onPick,
}: {
targets: SnapTarget[];
worldSize: number;
onPick: (point: MapPoint) => void;
}) {
const map = useMap();
const [preview, setPreview] = useState<SnapMatch | null>(null);
const snapAt = (event: L.LeafletMouseEvent): SnapMatch | null =>
findSnap(
[event.latlng.lng, -event.latlng.lat],
targets,
SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize),
);
useMapEvents({
mousemove: (event) => setPreview(snapAt(event)),
click: (event) => {
const match = snapAt(event);
if (match) {
onPick(match.point);
} else {
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
}
},
});
if (!preview) return null;
return (
<Marker
position={toLatLng(preview.point)}
interactive={false}
icon={snapPreviewIcon(preview.kind)}
/>
);
}
const vertexHandleIcon = L.divIcon({
className: "map-vertex-handle-icon",
html: '<div class="map-vertex-handle"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const midpointHandleIcon = L.divIcon({
className: "map-midpoint-handle-icon",
html: '<div class="map-midpoint-handle"></div>',
iconSize: [8, 8],
iconAnchor: [4, 4],
});
/**
* Draggable handles for the current draft's vertices: drag a square to move it
* (drag-end snaps to existing geometry), right-click deletes it, clicking the
* small hollow square on a segment inserts a vertex there.
*/
function DraftHandles({
points,
closed,
baseTargets,
worldSize,
onUpdate,
onInsert,
onRemove,
}: {
points: MapPoint[];
closed: boolean;
baseTargets: SnapTarget[];
worldSize: number;
onUpdate: (index: number, point: MapPoint) => void;
onInsert: (index: number, point: MapPoint) => void;
onRemove: (index: number) => void;
}) {
const map = useMap();
const midpoints = useMemo(() => segmentMidpoints(points, closed), [points, closed]);
const dragTargets = (index: number): SnapTarget[] => [
...baseTargets,
// Own vertices, minus the dragged one so a small drag cannot snap back.
{ key: "draft", vertices: points.filter((_, i) => i !== index), closed: false, edges: false },
];
return (
<>
{points.map((point, index) => (
<Marker
key={`draft-vertex-${index}`}
position={toLatLng(point)}
draggable
icon={vertexHandleIcon}
zIndexOffset={1000}
eventHandlers={{
dragend: (event) => {
const latlng = (event.target as L.Marker).getLatLng();
const raw: MapPoint = [latlng.lng, -latlng.lat];
const radius = SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize);
const match = findSnap(raw, dragTargets(index), radius);
onUpdate(index, match ? match.point : [Math.round(raw[0]), Math.round(raw[1])]);
},
contextmenu: () => onRemove(index),
}}
/>
))}
{midpoints.map((midpoint, index) => (
<Marker
key={`draft-midpoint-${index}-${midpoint.index}`}
position={toLatLng(midpoint.point)}
icon={midpointHandleIcon}
zIndexOffset={900}
eventHandlers={{ click: () => onInsert(midpoint.index, midpoint.point) }}
/>
))}
</>
);
}
function formatDuration(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
@ -576,38 +365,6 @@ function formatDuration(ms: number): string {
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
}
/** Footer-cell countdown: fixed-width "04H 07M 12S". */
function paddedCountdown(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${String(hours).padStart(2, "0")}H ${String(minutes).padStart(2, "0")}M ${String(seconds).padStart(2, "0")}S`;
}
/**
* Friendly completion target: today (or already due) shows the time only,
* within a week the weekday, beyond that the calendar date.
*/
function friendlyCompletion(completesMs: number, nowMs: number): string {
const target = new Date(completesMs);
const time = target.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(target) - startOfDay(new Date(nowMs))) / (24 * 60 * 60 * 1000),
);
if (dayDiff <= 0) return time;
if (dayDiff < 7) return `${target.toLocaleDateString([], { weekday: "long" })}, ${time}`;
return `${target.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
}
/** Ribbon ETA in whole minutes ("1H 05M") so the cached icon changes at most once per minute. */
function bucketedEta(ms: number): string {
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
return `${Math.floor(totalMinutes / 60)}H ${totalMinutes % 60}M`;
}
function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) {
return (
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
@ -676,9 +433,7 @@ function StructurePopup({
>
{status.label}
{pct != null ? ` // ${pct}%` : ""}
{building && Number.isFinite(completesMs)
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
: ""}
{Number.isFinite(remaining) ? ` // T-${formatDuration(remaining)}` : ""}
</div>
{structure.constructionStatus === "awaiting_materials" &&
structure.materials.length > 0 && (
@ -695,8 +450,13 @@ function StructurePopup({
{ label: "Grid", value: `${gridX}E / ${gridY}S` },
building
? {
label: "ETA",
value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
label: "Completes",
value: Number.isFinite(completesMs)
? new Date(completesMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "pending",
}
: {
label: "Storage",
@ -809,99 +569,18 @@ const StructureLayer = memo(function StructureLayer({
return winners;
}, [dots, zoom, worldSize]);
// Ribbon allocation at close zoom: same priority order as labels, greedy
// screen-space rectangles (pin + estimated ribbon width) so ribbons never
// cover another structure's pin or ribbon. Structures that lose the
// collision fall back to their hover tooltip.
const ribbonIds = useMemo(() => {
if (metersPerPx(zoom, worldSize) > STRUCTURE_RIBBON_M_PER_PX) return new Set<number>();
const placed: { left: number; right: number; top: number; bottom: number }[] = [];
const winners = new Set<number>();
const ordered = [...dots].sort(
(a, b) =>
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
a.structure.id - b.structure.id,
);
for (const dot of ordered) {
const point = map.latLngToLayerPoint(dot.position);
const width = Math.min(220, Math.max(110, Math.max(dot.structure.name.length, 24) * 6.2));
const left = point.x + RIBBON_PIN_HALF_PX + RIBBON_GAP_PX;
const rect = {
left,
right: left + width,
top: point.y - RIBBON_HEIGHT_PX / 2,
bottom: point.y + RIBBON_HEIGHT_PX / 2,
};
const collides = placed.some(
(other) =>
rect.left < other.right &&
rect.right > other.left &&
rect.top < other.bottom &&
rect.bottom > other.top,
);
if (collides) continue;
placed.push(rect);
winners.add(dot.structure.id);
}
return winners;
}, [dots, zoom, worldSize, map]);
return (
<>
{dots.map(({ structure, statusKey, position }) => {
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
const showLabel = labeledIds.has(structure.id);
const mPerPx = metersPerPx(zoom, worldSize);
const tier: StructurePinTier =
mPerPx <= STRUCTURE_RIBBON_M_PER_PX
? "lg"
: mPerPx <= STRUCTURE_PIN_MID_M_PER_PX
? "md"
: "sm";
let ribbonMeta: string;
if (statusKey === "building") {
const end = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
ribbonMeta = Number.isFinite(end)
? `ETA ${bucketedEta(Math.max(0, end - nowMs))}`
: "Building";
} else if (statusKey === "awaiting_materials") {
const first = structure.materials[0];
ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials";
} else {
ribbonMeta = structure.blueprintName ?? "Structure";
}
const ribbon: StructureRibbon | null =
tier === "lg" && ribbonIds.has(structure.id)
? {
tier,
name: structure.name ?? `Structure ${structure.id}`,
meta: ribbonMeta,
}
: null;
return (
<Marker
key={`structure-${structure.id}`}
position={position}
icon={getStructureIcon(statusKey, pct, ribbon)}
icon={getStructureIcon(statusKey, pct)}
>
{tier === "lg" ? (
ribbon ? null : (
<Tooltip
direction="top"
offset={[0, -16]}
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip>
)
) : showLabel ? (
{showLabel ? (
<Tooltip
permanent
direction="right"
@ -918,8 +597,7 @@ const StructureLayer = memo(function StructureLayer({
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
{structure.name}
</Tooltip>
)}
<Popup className="map-popup-dossier">
@ -934,16 +612,8 @@ const StructureLayer = memo(function StructureLayer({
const RoadLabelsLayer = memo(function RoadLabelsLayer({
roads,
worldSize,
}: {
roads: {
id: number;
name: string;
labelVisible: boolean | null;
surface: "paved" | "dirt" | "trail";
points: MapPoint[];
}[];
worldSize: number;
roads: { id: number; name: string; labelVisible: boolean | null; points: MapPoint[] }[];
}) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
@ -953,9 +623,8 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
});
const labeledRoads = useMemo(() => {
const scale = metersPerPx(zoom, worldSize);
return roads
.filter((road) => road.name && roadLabelShown(road, scale))
.filter((road) => road.name && road.labelVisible !== false)
.flatMap((road) => {
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
const pxPoints = road.points.map((point) => {
@ -965,25 +634,6 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
const screenLength = polylineLength(pxPoints);
if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return [];
// Distant roads: a single straight label at the midpoint (following a
// tiny wiggly path folds the letters together). Close up: text follows
// the bends, character-aligned.
if (screenLength < ROAD_LABEL_CURVE_MIN_PX) {
const mid = interpolateAlong(road.points, 0.5);
if (!mid) return [];
return [
{
id: road.id,
name: road.name,
position: toLatLng(mid.point),
icon: straightLabelIcon(
road.name,
labelScreenAngle(road.points[0], road.points[road.points.length - 1]),
),
},
];
}
const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2;
// On very long roads a fixed interval would exhaust the label cap part
// way along the path; spread the capped labels over the whole road
@ -1009,7 +659,7 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
},
];
});
}, [map, roads, worldSize, zoom]);
}, [map, roads, zoom]);
return (
<>
@ -1195,11 +845,11 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
const zonePolys = useMemo(
() =>
(features?.zones ?? []).map((zone) => {
const defaults = ZONE_PATH_OPTIONS[zone.type];
const fallbackColor = zone.type === "water" ? "#38bdf8" : "#4ade80";
const options: L.PathOptions = {
color: zone.strokeColor || defaults.color,
fillColor: zone.fillColor || defaults.fillColor,
fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
color: zone.strokeColor || fallbackColor,
fillColor: zone.fillColor || fallbackColor,
fillOpacity: zone.fillOpacity ?? (zone.type === "water" ? 0.25 : 0.08),
weight: zone.strokeWeight ?? 1,
};
return { zone, positions: zone.points.map(toLatLng), options };
@ -1253,37 +903,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
[worldW, worldH],
);
const maxWorld = Math.max(worldW, worldH);
const snapTargets = useMemo<SnapTarget[]>(() => {
if (mode !== "edit" || authoring.kind == null) return [];
return [
...(features?.roads ?? []).map((road) => ({
key: `road-${road.id}`,
vertices: road.points,
closed: false,
edges: true,
})),
...(features?.zones ?? []).map((zone) => ({
key: `zone-${zone.id}`,
vertices: zone.points,
closed: true,
edges: true,
})),
...(features?.nodes ?? []).map((node) => ({
key: `node-${node.id}`,
vertices: [node.point],
closed: false,
edges: false,
})),
...(features?.structures ?? []).map((structure) => ({
key: `structure-${structure.id}`,
vertices: [structure.point],
closed: false,
edges: false,
})),
];
}, [mode, authoring.kind, features]);
if (maps.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
@ -1470,12 +1089,10 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
{features && toggles.roads && (
<RoadLabelsLayer
worldSize={Math.max(worldW, worldH)}
roads={features.roads.map((road) => ({
id: road.id,
name: road.name ?? "",
labelVisible: road.labelVisible,
surface: road.surface,
points: road.points,
}))}
/>
@ -1585,31 +1202,12 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
)}
<MousePosition />
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
{mapConfig && (
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
)}
{mode === "place" && <ClickHandler onPick={setPendingPoint} />}
{mode === "edit" && authoring.kind != null && (
<AuthoringSnapLayer
targets={snapTargets}
worldSize={maxWorld}
onPick={authoring.pushPoint}
/>
)}
{mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && (
<DraftHandles
points={authoring.draftPoints}
closed={authoring.kind === "zone"}
baseTargets={snapTargets}
worldSize={maxWorld}
onUpdate={authoring.updateDraftPoint}
onInsert={authoring.insertDraftPoint}
onRemove={authoring.removeDraftPoint}
/>
)}
{mode === "edit" && authoring.kind != null && <ClickHandler onPick={authoring.pushPoint} />}
{mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && (
<Polyline

View file

@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -14,13 +13,15 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
import {
validatePlacement,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement";
import type { MapPoint } from "@/lib/map/points";
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
@ -85,18 +86,9 @@ export function PlacementDialog({
terrainType?: string | null;
constructionDurationMinutes?: number | null;
requiredTech?: { name?: string }[] | number[];
materials?: {
resource?: { id: number; name?: string } | number;
amount?: number | null;
}[];
resourcesInRange?: {
resource?: { id: number } | number | null;
range?: number | null;
}[];
structuresInRange?: {
structure?: { id: number } | number | null;
range?: number | null;
}[];
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[];
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[];
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[];
}[];
};
setBlueprints(
@ -106,9 +98,7 @@ export function PlacementDialog({
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
requiredTech: (doc.requiredTech ?? [])
.filter(
(tech): tech is { name?: string } => typeof tech === "object" && tech !== null,
)
.filter((tech): tech is { name?: string } => typeof tech === "object" && tech !== null)
.map((tech) => tech.name ?? "Unknown"),
materials: (doc.materials ?? []).map((material) => ({
resource:
@ -245,18 +235,18 @@ export function PlacementDialog({
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-blueprint">Blueprint</Label>
<Combobox
id="place-blueprint"
value={blueprintId}
onValueChange={setBlueprintId}
placeholder="Select blueprint"
searchPlaceholder="Search blueprints..."
emptyMessage="No blueprints available."
options={blueprints.map((option) => ({
value: String(option.id),
label: option.name,
}))}
/>
<Select value={blueprintId} onValueChange={setBlueprintId}>
<SelectTrigger id="place-blueprint" className="w-full">
<SelectValue placeholder="Select blueprint" />
</SelectTrigger>
<SelectContent>
{blueprints.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
@ -271,20 +261,19 @@ export function PlacementDialog({
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-faction">Faction (optional)</Label>
<Combobox
id="place-faction"
value={factionId}
onValueChange={setFactionId}
searchPlaceholder="Search factions..."
emptyMessage="No factions available."
options={[
{ value: "none", label: "No faction" },
...factions.map((faction) => ({
value: String(faction.id),
label: faction.name,
})),
]}
/>
<Select value={factionId} onValueChange={setFactionId}>
<SelectTrigger id="place-faction" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No faction</SelectItem>
{factions.map((faction) => (
<SelectItem key={faction.id} value={String(faction.id)}>
{faction.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{blueprint && (

View file

@ -5,11 +5,17 @@ import { useRouter } from "next/navigation";
import type { Asset, LockerStorage } from "@/payload-types";
import { PlusIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@ -190,21 +196,28 @@ export function CreateListingDialog({
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Item</Label>
<Combobox
<Select
value={assetId}
onValueChange={(v) => {
setAssetId(v);
setQuantity("1");
}}
placeholder="Select an item from your locker"
searchPlaceholder="Search items..."
emptyMessage="Nothing is approved for trading yet."
options={sellable.map((s) => ({
value: String(s.assetId),
label: `${s.name} (${s.quantity.toLocaleString()}×)`,
disabled: !s.tradeable || !s.isLive,
}))}
/>
>
<SelectTrigger>
<SelectValue placeholder="Select an item from your locker" />
</SelectTrigger>
<SelectContent>
{sellable.map((s) => (
<SelectItem
key={s.assetId}
value={String(s.assetId)}
disabled={!s.tradeable || !s.isLive}
>
{s.name} ({s.quantity.toLocaleString()}×)
</SelectItem>
))}
</SelectContent>
</Select>
{sellable.length === 0 && (
<p className="text-xs text-muted-foreground">
{hasLockerItems

View file

@ -10,7 +10,6 @@ import {
setPokerHoleQueue,
} from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -66,7 +65,11 @@ function cardLabel(card: Card): string {
* what lets a single pick land; the previous null-propagating version made
* first picks no-ops and hole-card queuing impossible.
*/
export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
export function applyCardPick(
value: Card | null,
part: "rank" | "suit",
next: string,
): Card {
if (part === "rank") {
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
}
@ -88,17 +91,18 @@ function CardPicker({
return (
<div className="flex items-center gap-1.5">
<span className="w-10 text-xs text-muted-foreground">{label}</span>
<Combobox
className="h-8 w-20 text-xs"
value={value ? String(value.rank) : ""}
onValueChange={setRank}
placeholder="Rank"
searchPlaceholder="Rank..."
options={RANK_OPTIONS.map((option) => ({
value: String(option.value),
label: option.label,
}))}
/>
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue placeholder="Rank" />
</SelectTrigger>
<SelectContent>
{RANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="Suit" />
@ -254,19 +258,20 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
<Combobox
className="h-8 text-xs"
value={seatId}
onValueChange={setSeatId}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: player.name,
}))}
/>
<Select value={seatId} onValueChange={setSeatId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-1.5">
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
@ -284,19 +289,20 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
<Combobox
className="h-8 text-xs"
value={adjustSeat}
onValueChange={setAdjustSeat}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: `${player.name} (${player.stack.toLocaleString()})`,
}))}
/>
<Select value={adjustSeat} onValueChange={setAdjustSeat}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name} ({player.stack.toLocaleString()})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input
type="number"

View file

@ -12,7 +12,6 @@ import {
} from "@/lib/evaluations/levels";
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -135,20 +134,20 @@ export function LeadershipEvaluationDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-mission">Mission</Label>
<Combobox
id="evaluation-mission"
aria-invalid={error !== null}
value={missionId}
onValueChange={setMissionId}
disabled={pending}
placeholder="Select a completed mission"
searchPlaceholder="Search missions..."
emptyMessage="No completed missions to evaluate."
options={missions.map((mission) => ({
value: String(mission.id),
label: `${mission.name} · ${formatMissionDate(mission.startDateTime)}`,
}))}
/>
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
<SelectValue placeholder="Select a completed mission" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{missions.map((mission) => (
<SelectItem key={mission.id} value={String(mission.id)}>
{mission.name} · {formatMissionDate(mission.startDateTime)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">

View file

@ -3,7 +3,6 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -12,6 +11,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
import { toast } from "sonner";
@ -91,16 +97,21 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Combobox
<Select
value={selectedRankId != null ? String(selectedRankId) : undefined}
onValueChange={(v) => setRankId(Number(v))}
placeholder="Select a rank"
searchPlaceholder="Search ranks..."
options={targetRanks.map((r) => ({
value: String(r.id),
label: r.name,
}))}
/>
>
<SelectTrigger>
<SelectValue placeholder="Select a rank" />
</SelectTrigger>
<SelectContent>
{targetRanks.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
@ -116,4 +127,4 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
</Dialog>
</section>
);
}
}

View file

@ -1,11 +1,8 @@
"use client";
import { useState } from "react";
import { TagsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -14,6 +11,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
function sprintFilterValue(value: string): SprintFilter {
@ -23,9 +29,7 @@ function sprintFilterValue(value: string): SprintFilter {
}
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
const active = sprints.filter((sprint) =>
["planned", "active", "in_progress"].includes(sprint.status),
);
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status));
return active.length > 0 ? active : sprints;
}
@ -39,12 +43,6 @@ export function KanbanFilters({
}: KanbanFiltersProps) {
const selectedLabelCount = selectedLabelIds.length;
const sprintOptions = activeSprints(sprints);
const [labelQuery, setLabelQuery] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false);
const labelNeedle = labelQuery.trim().toLowerCase();
const visibleLabels = labelNeedle
? labels.filter((label) => label.name.toLowerCase().includes(labelNeedle))
: labels;
const sprintLabel =
sprintFilter === "all"
? "All sprints"
@ -52,23 +50,27 @@ export function KanbanFilters({
return (
<div className="flex flex-wrap items-center gap-2">
<Combobox
<Select
value={String(sprintFilter)}
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
className="w-[190px]"
aria-label="Filter by sprint"
placeholder={sprintLabel ?? "Select sprint"}
searchPlaceholder="Search sprints..."
options={[
{ value: "all", label: "All sprints" },
...sprintOptions.map((sprint) => ({
value: String(sprint.id),
label: sprint.name,
})),
]}
/>
>
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Sprint</SelectLabel>
<SelectItem value="all">All sprints</SelectItem>
{sprintOptions.map((sprint) => (
<SelectItem key={sprint.id} value={String(sprint.id)}>
{sprint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<DropdownMenu open={labelsOpen} onOpenChange={setLabelsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Filter by label">
<TagsIcon data-icon="inline-start" />
@ -79,22 +81,10 @@ export function KanbanFilters({
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 pb-1.5">
<Input
value={labelQuery}
onChange={(event) => setLabelQuery(event.target.value)}
placeholder="Search labels..."
className="h-7 text-xs"
/>
</div>
{labels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
) : visibleLabels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">
No matching labels
</span>
) : (
visibleLabels.map((label) => (
labels.map((label) => (
<DropdownMenuCheckboxItem
key={label.id}
checked={selectedLabelIds.includes(label.id)}

View file

@ -11,7 +11,6 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface LogEntry {
id: number;
@ -38,8 +37,6 @@ export function EventLedger({
const [loading, setLoading] = useState(true);
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [typeQuery, setTypeQuery] = useState("");
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const initialDone = useRef(false);
const eventTypes = useMemo(() => {
@ -47,11 +44,6 @@ export function EventLedger({
return Array.from(set).sort();
}, [entries]);
const typeNeedle = typeQuery.trim().toLowerCase();
const visibleTypes = typeNeedle
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
: eventTypes;
const filtered = useMemo(
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
[entries, selectedTypes],
@ -146,13 +138,7 @@ export function EventLedger({
</ItemHeader>
<ItemContent>
{eventTypes.length > 1 && (
<DropdownMenu
open={filterMenuOpen}
onOpenChange={(next) => {
setFilterMenuOpen(next);
if (!next) setTypeQuery("");
}}
>
<DropdownMenu>
<span className="text-xs text-muted-foreground">Filter event logs...</span>
<DropdownMenuTrigger asChild>
<button
@ -162,39 +148,22 @@ export function EventLedger({
{dropdownLabel}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<div className="p-1.5">
<Input
value={typeQuery}
onChange={(event) => setTypeQuery(event.target.value)}
placeholder="Search event types..."
className="h-7 rounded-none text-xs"
/>
</div>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem
checked={selectedTypes.size === 0}
onCheckedChange={() => setSelectedTypes(new Set())}
onSelect={(event) => event.preventDefault()}
>
All
</DropdownMenuCheckboxItem>
<div className="max-h-64 overflow-y-auto">
{visibleTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
onSelect={(event) => event.preventDefault()}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
{visibleTypes.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No matches
</div>
)}
</div>
{eventTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}

View file

@ -9,7 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
@ -509,20 +508,21 @@ export function ManageStorageDialog({
<div className="flex flex-col gap-2">
<Label>Transport Vehicle</Label>
{availableVehicles.length > 0 ? (
<Combobox
value={shipmentVehicleId}
onValueChange={setShipmentVehicleId}
placeholder="Select a vehicle..."
searchPlaceholder="Search vehicles..."
emptyMessage="No vehicles with range."
options={availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return {
value: String(v.id),
label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
};
})}
/>
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
<SelectTrigger>
<SelectValue placeholder="Select a vehicle..." />
</SelectTrigger>
<SelectContent>
{availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return (
<SelectItem key={v.id} value={String(v.id)}>
{v.name} ({vType?.name ?? "Unknown"}), {v.currentFuel?.toFixed(0)} fuel
</SelectItem>
);
})}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" />
@ -684,7 +684,7 @@ export function ManageStorageDialog({
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
<Select
value={depositResourceId}
onValueChange={(val) => {
setDepositResourceId(val);
@ -692,20 +692,29 @@ export function ManageStorageDialog({
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
);
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing may be deposited here."
options={allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return {
value: String(resource.id),
label: resource.name,
hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
};
})}
/>
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
@ -745,27 +754,30 @@ export function ManageStorageDialog({
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
<Select
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
@ -805,43 +817,51 @@ export function ManageStorageDialog({
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Combobox
<Select
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}}
placeholder="Select a structure..."
searchPlaceholder="Search structures..."
options={allStructures.map((s) => ({
value: String(s.id),
label: s.name,
}))}
/>
>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
<Select
value={transferResourceId}
onValueChange={(val) => {
setTransferResourceId(val);
const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount));
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>

View file

@ -1,122 +0,0 @@
"use client";
import * as React from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export interface ComboboxOption {
value: string;
label: string;
/** Optional secondary line rendered under the label. */
hint?: string;
/** Disabled options render muted and cannot be selected. */
disabled?: boolean;
}
interface ComboboxProps extends React.ComponentPropsWithoutRef<"button"> {
value?: string;
onValueChange: (value: string) => void;
options: ComboboxOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
/** Trigger sizing classes (the dropdown keeps the trigger's width). */
className?: string;
/** Popover content classes. */
contentClassName?: string;
}
/**
* Searchable single-value dropdown (Popover + cmdk), a drop-in replacement for
* ui/select wherever the item list is data-driven and can grow long. The
* popover stays as wide as the trigger and the list scrolls at 16rem.
*/
function Combobox({
value,
onValueChange,
options,
placeholder = "Select...",
searchPlaceholder = "Search...",
emptyMessage = "No matches.",
disabled = false,
className,
contentClassName,
...rest
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const selected = options.find((option) => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
role="combobox"
aria-expanded={open}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"disabled:cursor-not-allowed disabled:opacity-50",
!selected && "text-muted-foreground",
className,
)}
{...rest}
>
<span className="truncate">{selected ? selected.label : placeholder}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</PopoverTrigger>
<PopoverContent
className={cn("w-[var(--radix-popover-trigger-width)] p-0", contentClassName)}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
disabled={option.disabled}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-1 size-4",
option.value === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.hint && (
<span className="block truncate text-xs text-muted-foreground">
{option.hint}
</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export { Combobox };

View file

@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
export interface ZoneInput {
id: number;
name?: string | null;
type: "water" | "land" | "territory" | "border";
type: "water" | "land";
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
resources?: number[];
points: MapPoint[];
@ -112,7 +112,8 @@ export function checkResourcesInRange(
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
);
const zoneMatch = zones.some(
(zone) => (zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
(zone) =>
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
);
if (!nodeMatch && !zoneMatch) {
return {

View file

@ -1,95 +0,0 @@
import { pointDistance, type MapPoint } from "./points";
export type SnapKind = "vertex" | "edge";
export interface SnapTarget {
key: string;
vertices: MapPoint[];
/** Polygon target: the closing segment (last -> first vertex) participates. */
closed: boolean;
/** Offer edge snapping (nearest point on each segment), not just vertices. */
edges: boolean;
}
export interface SnapMatch {
point: MapPoint;
kind: SnapKind;
targetKey: string;
/** Distance in meters from the cursor to the matched geometry. */
distance: number;
}
/** Nearest point to p on segment ab (projection clamped to the endpoints). */
export function closestPointOnSegment(p: MapPoint, a: MapPoint, b: MapPoint): MapPoint {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const lengthSq = abx * abx + aby * aby;
if (lengthSq === 0) return [a[0], a[1]];
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / lengthSq));
return [a[0] + abx * t, a[1] + aby * t];
}
/**
* Nearest snap within radiusMeters. Vertices own their neighborhood: an edge
* only wins when it is at least twice as close as the nearest vertex (clicks
* near a corner mean the corner, not one of the edges meeting there). Returns null when
* nothing is in range.
*/
export function findSnap(
point: MapPoint,
targets: readonly SnapTarget[],
radiusMeters: number,
): SnapMatch | null {
if (radiusMeters <= 0) return null;
let bestVertex: SnapMatch | null = null;
let bestEdge: SnapMatch | null = null;
for (const target of targets) {
for (const vertex of target.vertices) {
const distance = pointDistance(point, vertex);
if (distance <= radiusMeters && (!bestVertex || distance < bestVertex.distance)) {
bestVertex = {
point: [vertex[0], vertex[1]],
kind: "vertex",
targetKey: target.key,
distance,
};
}
}
if (!target.edges) continue;
const last = target.closed ? target.vertices.length : target.vertices.length - 1;
for (let i = 0; i < last; i++) {
const a = target.vertices[i];
const b = target.vertices[(i + 1) % target.vertices.length];
const projected = closestPointOnSegment(point, a, b);
const distance = pointDistance(point, projected);
if (distance <= radiusMeters && (!bestEdge || distance < bestEdge.distance)) {
bestEdge = { point: projected, kind: "edge", targetKey: target.key, distance };
}
}
}
if (bestVertex && (!bestEdge || bestEdge.distance >= bestVertex.distance * 0.5)) {
return bestVertex;
}
return bestEdge ?? bestVertex;
}
export interface SegmentMidpoint {
/** Insert position in the point array: i + 1 (appends for the closing segment). */
index: number;
point: MapPoint;
}
/** Midpoint of every segment, for click-to-insert vertex handles. */
export function segmentMidpoints(points: MapPoint[], closed: boolean): SegmentMidpoint[] {
const midpoints: SegmentMidpoint[] = [];
if (points.length < 2) return midpoints;
const segments = closed ? points.length : points.length - 1;
for (let i = 0; i < segments; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
midpoints.push({ index: i + 1, point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] });
}
return midpoints;
}

View file

@ -147,37 +147,3 @@ function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
if (dx === 0 && dy === 0) return 0;
return (Math.atan2(dy, dx) * 180) / Math.PI;
}
/**
* CSS rotation for a straight label running along the from->to direction:
* normalized into [-90, 90] (never upside down) with vertical text reading
* bottom-to-top.
*/
export function labelScreenAngle(from: MapPoint, to: MapPoint): number {
const angle = segmentAngleDeg(from, to);
if (angle > 90) return angle - 180;
if (angle < -90) return angle + 180;
if (angle === 90) return -90;
return angle;
}
/**
* Minor roads (dirt/trail) only carry a name label this close to the ground,
* in meters per pixel (zoom >= 3 on an 8192 m world).
*/
export const MINOR_ROAD_LABEL_MAX_M_PER_PX = 4;
/**
* Tri-state road label rule: an explicit checkbox value wins; unset means
* auto, where paved roads always label and dirt/trail roads label only at
* close zoom (metersPerPx at or below MINOR_ROAD_LABEL_MAX_M_PER_PX; pass
* null/undefined to evaluate the zoom-independent part).
*/
export function roadLabelShown(
road: { labelVisible?: boolean | null; surface: string },
metersPerPx?: number | null,
): boolean {
if (road.labelVisible != null) return road.labelVisible;
if (road.surface === "paved") return true;
return metersPerPx != null && metersPerPx <= MINOR_ROAD_LABEL_MAX_M_PER_PX;
}

View file

@ -67,7 +67,7 @@ export interface ZoneFeature {
id: number;
name: string | null;
description: string | null;
type: "water" | "land" | "territory" | "border";
type: "water" | "land";
resources: { id: number; name: string }[];
fillColor: string | null;
strokeColor: string | null;

View file

@ -1,29 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Political-boundary zone types (Postgres 12+ allows ADD VALUE inside the
// migration's transaction as long as the new values are not used in it).
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'territory';
`);
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'border';
`);
// Road labels become tri-state: NULL = auto (paved only). Existing rows all
// carry the old column default (true), which was never a deliberate choice,
// so clear it on non-paved roads to make the paved-only default real.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" DROP DEFAULT;
`);
await db.execute(sql`
UPDATE "map_roads" SET "label_visible" = NULL WHERE "surface" IS DISTINCT FROM 'paved';
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Enum values cannot be removed in Postgres; restore the old road default.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" SET DEFAULT true;
`);
}

View file

@ -1,405 +1,399 @@
import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
import * as migration_20260907_090245_add_mission_ready_reminder_fields from "./20260907_090245_add_mission_ready_reminder_fields";
import * as migration_20260907_120754_add_story_beats from "./20260907_120754_add_story_beats";
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from "./20260907_124024_convert_story_beats_body_to_lexical";
import * as migration_20260908_152021_add_opord_recall_minigame_stats from "./20260908_152021_add_opord_recall_minigame_stats";
import * as migration_20260908_172241_add_custom_minigame_collections from "./20260908_172241_add_custom_minigame_collections";
import * as migration_20260908_193107_add_creator_xp_bank_field from "./20260908_193107_add_creator_xp_bank_field";
import * as migration_20260908_194435_add_expose_edge_mines_field from "./20260908_194435_add_expose_edge_mines_field";
import * as migration_20260909_153427_add_opord_recall_best_times from "./20260909_153427_add_opord_recall_best_times";
import * as migration_20260909_223955_add_voice_script_draft_status from "./20260909_223955_add_voice_script_draft_status";
import * as migration_20260910_221018_make_missions_codename_optional from "./20260910_221018_make_missions_codename_optional";
import * as migration_20260910_222518_add_dashboard_layout_preference from "./20260910_222518_add_dashboard_layout_preference";
import * as migration_20260911_044754_add_poker_minigame_stats from "./20260911_044754_add_poker_minigame_stats";
import * as migration_20260911_052147_add_ribbon_submissions from "./20260911_052147_add_ribbon_submissions";
import * as migration_20260911_201559_add_award_advancement_series from "./20260911_201559_add_award_advancement_series";
import * as migration_20260912_051752_add_rank_promotion_ceiling from "./20260912_051752_add_rank_promotion_ceiling";
import * as migration_20260912_071740_add_blackjack_minigame_stats from "./20260912_071740_add_blackjack_minigame_stats";
import * as migration_20260912_231219_add_discord_cancelled_at from "./20260912_231219_add_discord_cancelled_at";
import * as migration_20260914_153046_add_chip_bank from "./20260914_153046_add_chip_bank";
import * as migration_20260914_171412_add_respawn_tickets from "./20260914_171412_add_respawn_tickets";
import * as migration_20260916_083158_add_game_map_system from "./20260916_083158_add_game_map_system";
import * as migration_20260916_185544_construction_timestamp_model from "./20260916_185544_construction_timestamp_model";
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional';
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
import * as migration_20260912_051752_add_rank_promotion_ceiling from './20260912_051752_add_rank_promotion_ceiling';
import * as migration_20260912_071740_add_blackjack_minigame_stats from './20260912_071740_add_blackjack_minigame_stats';
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
import * as migration_20260914_153046_add_chip_bank from './20260914_153046_add_chip_bank';
import * as migration_20260914_171412_add_respawn_tickets from './20260914_171412_add_respawn_tickets';
import * as migration_20260916_083158_add_game_map_system from './20260916_083158_add_game_map_system';
import * as migration_20260916_185544_construction_timestamp_model from './20260916_185544_construction_timestamp_model';
import * as migration_20260916_220224_add_map_labels_and_styling from './20260916_220224_add_map_labels_and_styling';
import * as migration_20260917_023028_add_node_and_zone_resources from './20260917_023028_add_node_and_zone_resources';
export const migrations = [
{
up: migration_20260820_004157.up,
down: migration_20260820_004157.down,
name: "20260820_004157",
name: '20260820_004157',
},
{
up: migration_20260825_220000.up,
down: migration_20260825_220000.down,
name: "20260825_220000",
name: '20260825_220000',
},
{
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: "20260826_050000_repair_shims_auxiliary_tables",
name: '20260826_050000_repair_shims_auxiliary_tables',
},
{
up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down,
name: "20260826_060000_add_game_servers_event_target",
name: '20260826_060000_add_game_servers_event_target',
},
{
up: migration_20260827_005757.up,
down: migration_20260827_005757.down,
name: "20260827_005757",
name: '20260827_005757',
},
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: "20260827_022334_add_eod_minesweeper_best_flags",
name: '20260827_022334_add_eod_minesweeper_best_flags',
},
{
up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down,
name: "20260827_213857_add_evaluations_collection",
name: '20260827_213857_add_evaluations_collection',
},
{
up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down,
name: "20260827_233224_add_mission_completed_at",
name: '20260827_233224_add_mission_completed_at',
},
{
up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down,
name: "20260828_011500_add_background_texture_preference",
name: '20260828_011500_add_background_texture_preference',
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: "20260828_041615_add_campaign_mode",
name: '20260828_041615_add_campaign_mode',
},
{
up: migration_20260828_044506.up,
down: migration_20260828_044506.down,
name: "20260828_044506",
name: '20260828_044506',
},
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: "20260831_142503",
name: '20260831_142503',
},
{
up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down,
name: "20260831_180233_assignment_transfers",
name: '20260831_180233_assignment_transfers',
},
{
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: "20260901_014006_add_assignment_transfer_event_target",
name: '20260901_014006_add_assignment_transfer_event_target',
},
{
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: "20260901_213108_add_ticket_votes_and_edit_history",
name: '20260901_213108_add_ticket_votes_and_edit_history',
},
{
up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down,
name: "20260901_222200_add_admin_page_permissions",
name: '20260901_222200_add_admin_page_permissions',
},
{
up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down,
name: "20260902_195336_add_medal_backgrounds",
name: '20260902_195336_add_medal_backgrounds',
},
{
up: migration_20260903_035544.up,
down: migration_20260903_035544.down,
name: "20260903_035544",
name: '20260903_035544',
},
{
up: migration_20260903_044047.up,
down: migration_20260903_044047.down,
name: "20260903_044047",
name: '20260903_044047',
},
{
up: migration_20260903_053419.up,
down: migration_20260903_053419.down,
name: "20260903_053419",
name: '20260903_053419',
},
{
up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down,
name: "20260903_060000_add_radio_mode_stats",
name: '20260903_060000_add_radio_mode_stats',
},
{
up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: "20260903_061000_add_radio_incorrect_counts",
name: '20260903_061000_add_radio_incorrect_counts',
},
{
up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down,
name: "20260903_062000_add_aim_trainer_stats",
name: '20260903_062000_add_aim_trainer_stats',
},
{
up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down,
name: "20260904_230838_add_wiki_collections",
name: '20260904_230838_add_wiki_collections',
},
{
up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down,
name: "20260905_183940_base_management",
name: '20260905_183940_base_management',
},
{
up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down,
name: "20260905_195456_labor_headcount",
name: '20260905_195456_labor_headcount',
},
{
up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down,
name: "20260905_210310_labor_classifications",
name: '20260905_210310_labor_classifications',
},
{
up: migration_20260905_213824.up,
down: migration_20260905_213824.down,
name: "20260905_213824",
name: '20260905_213824',
},
{
up: migration_20260905_215658.up,
down: migration_20260905_215658.down,
name: "20260905_215658",
name: '20260905_215658',
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: "20260906_045502",
name: '20260906_045502',
},
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: "20260906_050900_add_evaluation_reminders_field",
name: '20260906_050900_add_evaluation_reminders_field',
},
{
up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down,
name: "20260906_061451_add_wiki_template_css",
name: '20260906_061451_add_wiki_template_css',
},
{
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: "20260906_063753_add_personal_excerpt_richtext",
name: '20260906_063753_add_personal_excerpt_richtext',
},
{
up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
name: "20260906_065405_add_wiki_template_html",
name: '20260906_065405_add_wiki_template_html',
},
{
up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down,
name: "20260906_100908_add_market_economy_state",
name: '20260906_100908_add_market_economy_state',
},
{
up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down,
name: "20260906_165837_add_market_state_event_target",
name: '20260906_165837_add_market_state_event_target',
},
{
up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down,
name: "20260906_202925_add_voice_submissions",
name: '20260906_202925_add_voice_submissions',
},
{
up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down,
name: "20260907_012326_add_missions_created_by",
name: '20260907_012326_add_missions_created_by',
},
{
up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down,
name: "20260907_013724_make_missions_campaign_optional",
name: '20260907_013724_make_missions_campaign_optional',
},
{
up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down,
name: "20260907_041051_add_voice_script_spoiler",
name: '20260907_041051_add_voice_script_spoiler',
},
{
up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down,
name: "20260907_043339_add_voice_script_description",
name: '20260907_043339_add_voice_script_description',
},
{
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: "20260907_044934_add_missions_discord_modlist_hash",
name: '20260907_044934_add_missions_discord_modlist_hash',
},
{
up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down,
name: "20260907_083010_add_voice_submission_description",
name: '20260907_083010_add_voice_submission_description',
},
{
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: "20260907_090245_add_mission_ready_reminder_fields",
name: '20260907_090245_add_mission_ready_reminder_fields',
},
{
up: migration_20260907_120754_add_story_beats.up,
down: migration_20260907_120754_add_story_beats.down,
name: "20260907_120754_add_story_beats",
name: '20260907_120754_add_story_beats',
},
{
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
name: "20260907_124024_convert_story_beats_body_to_lexical",
name: '20260907_124024_convert_story_beats_body_to_lexical',
},
{
up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: "20260908_152021_add_opord_recall_minigame_stats",
name: '20260908_152021_add_opord_recall_minigame_stats',
},
{
up: migration_20260908_172241_add_custom_minigame_collections.up,
down: migration_20260908_172241_add_custom_minigame_collections.down,
name: "20260908_172241_add_custom_minigame_collections",
name: '20260908_172241_add_custom_minigame_collections',
},
{
up: migration_20260908_193107_add_creator_xp_bank_field.up,
down: migration_20260908_193107_add_creator_xp_bank_field.down,
name: "20260908_193107_add_creator_xp_bank_field",
name: '20260908_193107_add_creator_xp_bank_field',
},
{
up: migration_20260908_194435_add_expose_edge_mines_field.up,
down: migration_20260908_194435_add_expose_edge_mines_field.down,
name: "20260908_194435_add_expose_edge_mines_field",
name: '20260908_194435_add_expose_edge_mines_field',
},
{
up: migration_20260909_153427_add_opord_recall_best_times.up,
down: migration_20260909_153427_add_opord_recall_best_times.down,
name: "20260909_153427_add_opord_recall_best_times",
name: '20260909_153427_add_opord_recall_best_times',
},
{
up: migration_20260909_223955_add_voice_script_draft_status.up,
down: migration_20260909_223955_add_voice_script_draft_status.down,
name: "20260909_223955_add_voice_script_draft_status",
name: '20260909_223955_add_voice_script_draft_status',
},
{
up: migration_20260910_221018_make_missions_codename_optional.up,
down: migration_20260910_221018_make_missions_codename_optional.down,
name: "20260910_221018_make_missions_codename_optional",
name: '20260910_221018_make_missions_codename_optional',
},
{
up: migration_20260910_222518_add_dashboard_layout_preference.up,
down: migration_20260910_222518_add_dashboard_layout_preference.down,
name: "20260910_222518_add_dashboard_layout_preference",
name: '20260910_222518_add_dashboard_layout_preference',
},
{
up: migration_20260911_044754_add_poker_minigame_stats.up,
down: migration_20260911_044754_add_poker_minigame_stats.down,
name: "20260911_044754_add_poker_minigame_stats",
name: '20260911_044754_add_poker_minigame_stats',
},
{
up: migration_20260911_052147_add_ribbon_submissions.up,
down: migration_20260911_052147_add_ribbon_submissions.down,
name: "20260911_052147_add_ribbon_submissions",
name: '20260911_052147_add_ribbon_submissions',
},
{
up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down,
name: "20260911_201559_add_award_advancement_series",
name: '20260911_201559_add_award_advancement_series',
},
{
up: migration_20260912_051752_add_rank_promotion_ceiling.up,
down: migration_20260912_051752_add_rank_promotion_ceiling.down,
name: "20260912_051752_add_rank_promotion_ceiling",
name: '20260912_051752_add_rank_promotion_ceiling',
},
{
up: migration_20260912_071740_add_blackjack_minigame_stats.up,
down: migration_20260912_071740_add_blackjack_minigame_stats.down,
name: "20260912_071740_add_blackjack_minigame_stats",
name: '20260912_071740_add_blackjack_minigame_stats',
},
{
up: migration_20260912_231219_add_discord_cancelled_at.up,
down: migration_20260912_231219_add_discord_cancelled_at.down,
name: "20260912_231219_add_discord_cancelled_at",
name: '20260912_231219_add_discord_cancelled_at',
},
{
up: migration_20260914_153046_add_chip_bank.up,
down: migration_20260914_153046_add_chip_bank.down,
name: "20260914_153046_add_chip_bank",
name: '20260914_153046_add_chip_bank',
},
{
up: migration_20260914_171412_add_respawn_tickets.up,
down: migration_20260914_171412_add_respawn_tickets.down,
name: "20260914_171412_add_respawn_tickets",
name: '20260914_171412_add_respawn_tickets',
},
{
up: migration_20260916_083158_add_game_map_system.up,
down: migration_20260916_083158_add_game_map_system.down,
name: "20260916_083158_add_game_map_system",
name: '20260916_083158_add_game_map_system',
},
{
up: migration_20260916_185544_construction_timestamp_model.up,
down: migration_20260916_185544_construction_timestamp_model.down,
name: "20260916_185544_construction_timestamp_model",
name: '20260916_185544_construction_timestamp_model',
},
{
up: migration_20260916_220224_add_map_labels_and_styling.up,
down: migration_20260916_220224_add_map_labels_and_styling.down,
name: "20260916_220224_add_map_labels_and_styling",
name: '20260916_220224_add_map_labels_and_styling',
},
{
up: migration_20260917_023028_add_node_and_zone_resources.up,
down: migration_20260917_023028_add_node_and_zone_resources.down,
name: "20260917_023028_add_node_and_zone_resources",
},
{
up: migration_20260917_112258_add_zone_political_types.up,
down: migration_20260917_112258_add_zone_political_types.down,
name: "20260917_112258_add_zone_political_types",
name: '20260917_023028_add_node_and_zone_resources'
},
];

View file

@ -828,17 +828,7 @@ export const enum_releases_status = pgEnum("enum_releases_status", [
]);
export const enum_maps_basemap_mode = pgEnum("enum_maps_basemap_mode", ["image", "tiles"]);
export const enum_map_roads_surface = pgEnum("enum_map_roads_surface", ["paved", "dirt", "trail"]);
export const enum_map_roads_stroke_style = pgEnum("enum_map_roads_stroke_style", [
"solid",
"dashed",
"dotted",
]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", [
"water",
"land",
"territory",
"border",
]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", ["water", "land"]);
export const enum_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [
"manual",
"mission_complete",
@ -1076,8 +1066,6 @@ export const game_structures = pgTable(
}),
maintenanceCost: numeric("maintenance_cost", { mode: "number" }).default(0),
coordinates: geometryColumn("coordinates"),
labelVisible: boolean("label_visible").default(true),
labelPriority: numeric("label_priority", { mode: "number" }).default(0),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
@ -4799,7 +4787,6 @@ export const map_roads = pgTable(
{
id: serial("id").primaryKey(),
name: varchar("name"),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
@ -4807,10 +4794,6 @@ export const map_roads = pgTable(
}),
surface: enum_map_roads_surface("surface").notNull().default("paved"),
speedMultiplier: numeric("speed_multiplier", { mode: "number" }).notNull().default(1),
strokeColor: varchar("stroke_color"),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(2),
strokeStyle: enum_map_roads_stroke_style("stroke_style").default("solid"),
labelVisible: boolean("label_visible"),
points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
@ -4831,18 +4814,12 @@ export const map_zones = pgTable(
{
id: serial("id").primaryKey(),
name: varchar("name"),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
onDelete: "set null",
}),
type: enum_map_zones_type("type").notNull(),
fillColor: varchar("fill_color"),
strokeColor: varchar("stroke_color"),
fillOpacity: numeric("fill_opacity", { mode: "number" }),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(1),
labelVisible: boolean("label_visible").default(true),
points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow()
@ -4858,44 +4835,21 @@ export const map_zones = pgTable(
],
);
export const map_zones_rels = pgTable(
"map_zones_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("map_zones_rels_order_idx").on(columns.order),
index("map_zones_rels_parent_idx").on(columns.parent),
index("map_zones_rels_path_idx").on(columns.path),
index("map_zones_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [map_zones.id],
name: "map_zones_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "map_zones_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const resource_nodes = pgTable(
"resource_nodes",
{
id: serial("id").primaryKey(),
name: varchar("name").notNull(),
description: varchar("description"),
map: integer("map_id")
.notNull()
.references(() => maps.id, {
onDelete: "set null",
}),
resource: integer("resource_id")
.notNull()
.references(() => resources.id, {
onDelete: "set null",
}),
position: jsonb("position").notNull(),
richness: numeric("richness", { mode: "number" }).default(100),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
@ -4907,38 +4861,12 @@ export const resource_nodes = pgTable(
},
(columns) => [
index("resource_nodes_map_idx").on(columns.map),
index("resource_nodes_resource_idx").on(columns.resource),
index("resource_nodes_updated_at_idx").on(columns.updatedAt),
index("resource_nodes_created_at_idx").on(columns.createdAt),
],
);
export const resource_nodes_rels = pgTable(
"resource_nodes_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("resource_nodes_rels_order_idx").on(columns.order),
index("resource_nodes_rels_parent_idx").on(columns.parent),
index("resource_nodes_rels_path_idx").on(columns.path),
index("resource_nodes_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [resource_nodes.id],
name: "resource_nodes_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "resource_nodes_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const narrative_events = pgTable(
"narrative_events",
{
@ -8144,48 +8072,23 @@ export const relations_map_roads = relations(map_roads, ({ one }) => ({
relationName: "map",
}),
}));
export const relations_map_zones_rels = relations(map_zones_rels, ({ one }) => ({
parent: one(map_zones, {
fields: [map_zones_rels.parent],
references: [map_zones.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [map_zones_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_map_zones = relations(map_zones, ({ one, many }) => ({
export const relations_map_zones = relations(map_zones, ({ one }) => ({
map: one(maps, {
fields: [map_zones.map],
references: [maps.id],
relationName: "map",
}),
_rels: many(map_zones_rels, {
relationName: "_rels",
}),
}));
export const relations_resource_nodes_rels = relations(resource_nodes_rels, ({ one }) => ({
parent: one(resource_nodes, {
fields: [resource_nodes_rels.parent],
references: [resource_nodes.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [resource_nodes_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_resource_nodes = relations(resource_nodes, ({ one, many }) => ({
export const relations_resource_nodes = relations(resource_nodes, ({ one }) => ({
map: one(maps, {
fields: [resource_nodes.map],
references: [maps.id],
relationName: "map",
}),
_rels: many(resource_nodes_rels, {
relationName: "_rels",
resource: one(resources, {
fields: [resource_nodes.resource],
references: [resources.id],
relationName: "resource",
}),
}));
export const relations_narrative_events = relations(narrative_events, () => ({}));
@ -8854,7 +8757,6 @@ type DatabaseSchema = {
enum_releases_status: typeof enum_releases_status;
enum_maps_basemap_mode: typeof enum_maps_basemap_mode;
enum_map_roads_surface: typeof enum_map_roads_surface;
enum_map_roads_stroke_style: typeof enum_map_roads_stroke_style;
enum_map_zones_type: typeof enum_map_zones_type;
enum_narrative_events_triggers_type: typeof enum_narrative_events_triggers_type;
enum_story_beats_status: typeof enum_story_beats_status;
@ -8985,9 +8887,7 @@ type DatabaseSchema = {
maps: typeof maps;
map_roads: typeof map_roads;
map_zones: typeof map_zones;
map_zones_rels: typeof map_zones_rels;
resource_nodes: typeof resource_nodes;
resource_nodes_rels: typeof resource_nodes_rels;
narrative_events: typeof narrative_events;
story_beats: typeof story_beats;
story_beat_states: typeof story_beat_states;
@ -9132,9 +9032,7 @@ type DatabaseSchema = {
relations_labels: typeof relations_labels;
relations_maps: typeof relations_maps;
relations_map_roads: typeof relations_map_roads;
relations_map_zones_rels: typeof relations_map_zones_rels;
relations_map_zones: typeof relations_map_zones;
relations_resource_nodes_rels: typeof relations_resource_nodes_rels;
relations_resource_nodes: typeof relations_resource_nodes;
relations_narrative_events: typeof relations_narrative_events;
relations_story_beats: typeof relations_story_beats;

View file

@ -3791,7 +3791,7 @@ export interface MapRoad {
strokeWeight?: number | null;
strokeStyle?: ('solid' | 'dashed' | 'dotted') | null;
/**
* Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.
* Renders the road's name along the line at zoom-dependent intervals.
*/
labelVisible?: boolean | null;
/**
@ -3825,9 +3825,9 @@ export interface MapZone {
description?: string | null;
map: number | Map;
/**
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.
*/
type: 'water' | 'land' | 'territory' | 'border';
type: 'water' | 'land';
/**
* Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.
*/
@ -3837,7 +3837,7 @@ export interface MapZone {
*/
strokeColor?: string | null;
/**
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).
*/
fillOpacity?: number | null;
/**

View file

@ -14,20 +14,12 @@ import type {
} from "@/payload-types";
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
import {
labelScreenAngle,
roadLabelShown,
metersPerPx,
polygonCentroid,
roadLabelPoints,
splinePolyline,
validateHexColor,
} from "@/lib/map/style";
import {
closestPointOnSegment,
findSnap,
segmentMidpoints,
type SnapTarget,
} from "@/lib/map/snapping";
import {
checkResourcesInRange,
checkStructuresInRange,
@ -513,28 +505,6 @@ describe("Game Map System", () => {
expect(short).toEqual(raw.slice(0, 2));
});
it("derives upright straight-label angles with vertical text reading up", () => {
expect(labelScreenAngle([0, 0], [1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [-1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [0, 1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [0, -1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [1000, 1000])).toBe(45);
expect(labelScreenAngle([0, 0], [-1000, 1000])).toBe(-45);
});
it("defaults road labels to paved roads, minor roads at close zoom, with explicit override", () => {
expect(roadLabelShown({ labelVisible: null, surface: "paved" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 3.9)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: true, surface: "dirt" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: false, surface: "paved" }, 4)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "paved" })).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
});
it("computes polygon centroids inside the ring", () => {
const centroid = polygonCentroid([
[0, 0],
@ -553,82 +523,6 @@ describe("Game Map System", () => {
});
});
describe("authoring snapping (pure)", () => {
it("projects to segments and clamps to endpoints", () => {
expect(closestPointOnSegment([500, 500], [0, 0], [1000, 0])).toEqual([500, 0]);
expect(closestPointOnSegment([-50, -50], [0, 0], [1000, 0])).toEqual([0, 0]);
expect(closestPointOnSegment([2000, 50], [0, 0], [1000, 0])).toEqual([1000, 0]);
expect(closestPointOnSegment([10, 10], [10, 10], [10, 10])).toEqual([10, 10]);
});
it("snaps to the nearest vertex, preferring vertices over edges on ties", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const corner = findSnap([30, 20], [zone], 100);
expect(corner?.kind).toBe("vertex");
expect(corner?.point).toEqual([0, 0]);
// On the middle of an edge (>= 10% closer than any vertex) the edge wins.
const edge = findSnap([500, 30], [zone], 100);
expect(edge?.kind).toBe("edge");
expect(edge?.point).toEqual([500, 0]);
});
it("includes the closing segment of closed targets and respects the radius", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const closing = findSnap([30, 500], [zone], 100);
expect(closing?.kind).toBe("edge");
expect(closing?.point).toEqual([0, 500]);
expect(findSnap([30, 500], [zone], 10)).toBeNull();
});
it("builds segment midpoints with insert indices for open and closed drafts", () => {
const open = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
false,
);
expect(open).toEqual([
{ index: 1, point: [50, 0] },
{ index: 2, point: [100, 50] },
]);
const closed = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
true,
);
expect(closed).toHaveLength(3);
expect(closed[2]).toEqual({ index: 3, point: [50, 50] });
});
});
describe("road routing (pure)", () => {
it("connects roads that share a vertex", () => {
const roads: RoadInput[] = [
@ -1189,98 +1083,6 @@ describe("Game Map System", () => {
expect(outside.success).toBe(false);
expect(outside.error).toContain("resource node");
});
it("accepts political-boundary zone types and leaves placement unaffected", async () => {
const territory = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Territory`,
type: "territory",
points: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(territory.id);
expect(territory.type).toBe("territory");
const border = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Border`,
type: "border",
points: [
[4000, 6000],
[5000, 6000],
[5000, 7000],
[4000, 7000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(border.id);
expect(border.type).toBe("border");
// Political zones are informational: a land blueprint inside one places
// normally (only water zones gate placement).
authSpy.mockResolvedValue({ user: logisticsUser });
const inside = await placeStructure({
mapId: mapA.id,
blueprintId: depotBlueprint.id,
name: `${RUN} Territory Site`,
x: 500,
y: 500,
});
expect(inside.success).toBe(true);
createdIds.gameStructures.push((inside.data as GameStructure).id);
});
it("stores road labels as tri-state with paved-only auto default", async () => {
const dirt = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Dirt Track`,
surface: "dirt",
speedMultiplier: 1,
points: [
[0, 9000],
[1000, 9000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(dirt.id);
expect(dirt.labelVisible).toBeNull();
const paved = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Paved Way`,
surface: "paved",
speedMultiplier: 1,
labelVisible: false,
points: [
[0, 9500],
[1000, 9500],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(paved.id);
expect(paved.labelVisible).toBe(false);
});
});
describe("construction timestamps", () => {