From cc0a7facd01692f13e960815f79af56b62bbad6b Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 17 Sep 2026 16:27:55 -0400 Subject: [PATCH] feat(map): political zone types, road label tri-state, authoring snapping lib - map-zones type gains territory and country border (informational political boundaries that never gate placement, default fills amber 0.1 / violet 0.06) - map-roads labelVisible is tri-state: unset means auto, where paved roads always label and dirt/trail only label at 4 m/px or finer (roadLabelShown) - migration clears the old implicit label default on non-paved roads - new pure snapping lib: findSnap (vertices own their neighborhood, edges must be twice as close), closestPointOnSegment, segmentMidpoints - tests for label angles, tri-state rule, snapping, political zones --- AGENTS.md | 6 +- docs/map-system.md | 24 +- src/collections/world/MapRoads.ts | 4 +- src/collections/world/MapZones.ts | 10 +- src/lib/map/placement.ts | 5 +- src/lib/map/snapping.ts | 95 ++++++ src/lib/map/style.ts | 34 +++ src/lib/map/types.ts | 2 +- ...0260917_112258_add_zone_political_types.ts | 29 ++ src/migrations/index.ts | 270 +++++++++--------- src/payload-generated-schema.ts | 128 ++++++++- src/payload-types.ts | 8 +- tests/int/map-system.int.spec.ts | 198 +++++++++++++ 13 files changed, 649 insertions(+), 164 deletions(-) create mode 100644 src/lib/map/snapping.ts create mode 100644 src/migrations/20260917_112258_add_zone_political_types.ts diff --git a/AGENTS.md b/AGENTS.md index 1b08866..4a91a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, `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 `:read` + `admin::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 (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 `:read` + `admin::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`). -- **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`. +- **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`. - **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"`. diff --git a/docs/map-system.md b/docs/map-system.md index 16b2b7a..5445c54 100644 --- a/docs/map-system.md +++ b/docs/map-system.md @@ -100,10 +100,14 @@ 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 toggle); Save. + (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. - **New zone**: click at least 3 vertices; set name, description, type - (water/land), and styling (fill color, edge color, fill opacity 0-1, edge - width, name-label toggle); Save. + (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. - **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 @@ -112,6 +116,20 @@ 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 diff --git a/src/collections/world/MapRoads.ts b/src/collections/world/MapRoads.ts index fc9cb63..acb3513 100644 --- a/src/collections/world/MapRoads.ts +++ b/src/collections/world/MapRoads.ts @@ -96,10 +96,10 @@ export const MapRoads: CollectionConfig = { { name: "labelVisible", type: "checkbox", - defaultValue: true, label: "Show Name Label", admin: { - description: "Renders the road's name along the line at zoom-dependent intervals.", + 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.", }, }, { diff --git a/src/collections/world/MapZones.ts b/src/collections/world/MapZones.ts index 5e5ad27..eb4ac84 100644 --- a/src/collections/world/MapZones.ts +++ b/src/collections/world/MapZones.ts @@ -48,10 +48,12 @@ 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.", + "Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).", }, }, { @@ -76,7 +78,8 @@ export const MapZones: CollectionConfig = { min: 0, max: 1, admin: { - description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).", + description: + "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).", }, }, { @@ -114,7 +117,8 @@ 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).", }, }, ], diff --git a/src/lib/map/placement.ts b/src/lib/map/placement.ts index 4784e08..81ac591 100644 --- a/src/lib/map/placement.ts +++ b/src/lib/map/placement.ts @@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water"; export interface ZoneInput { id: number; name?: string | null; - type: "water" | "land"; + type: "water" | "land" | "territory" | "border"; /** Resource ids contained in this zone; a site inside satisfies their requirements. */ resources?: number[]; points: MapPoint[]; @@ -112,8 +112,7 @@ 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 { diff --git a/src/lib/map/snapping.ts b/src/lib/map/snapping.ts new file mode 100644 index 0000000..d7f5bb3 --- /dev/null +++ b/src/lib/map/snapping.ts @@ -0,0 +1,95 @@ +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; +} diff --git a/src/lib/map/style.ts b/src/lib/map/style.ts index 0198652..351bd3a 100644 --- a/src/lib/map/style.ts +++ b/src/lib/map/style.ts @@ -147,3 +147,37 @@ 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; +} diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts index f360ffc..bd5a963 100644 --- a/src/lib/map/types.ts +++ b/src/lib/map/types.ts @@ -67,7 +67,7 @@ export interface ZoneFeature { id: number; name: string | null; description: string | null; - type: "water" | "land"; + type: "water" | "land" | "territory" | "border"; resources: { id: number; name: string }[]; fillColor: string | null; strokeColor: string | null; diff --git a/src/migrations/20260917_112258_add_zone_political_types.ts b/src/migrations/20260917_112258_add_zone_political_types.ts new file mode 100644 index 0000000..d9b85bf --- /dev/null +++ b/src/migrations/20260917_112258_add_zone_political_types.ts @@ -0,0 +1,29 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres"; + +export async function up({ db }: MigrateUpArgs): Promise { + // 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 { + // 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; + `); +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 189169c..afb154d 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,399 +1,405 @@ -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_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"; 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' + 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", }, ]; diff --git a/src/payload-generated-schema.ts b/src/payload-generated-schema.ts index 0b94a73..21d6a37 100644 --- a/src/payload-generated-schema.ts +++ b/src/payload-generated-schema.ts @@ -828,7 +828,17 @@ 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_zones_type = pgEnum("enum_map_zones_type", ["water", "land"]); +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_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [ "manual", "mission_complete", @@ -1066,6 +1076,8 @@ 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(), @@ -4787,6 +4799,7 @@ export const map_roads = pgTable( { id: serial("id").primaryKey(), name: varchar("name"), + description: varchar("description"), map: integer("map_id") .notNull() .references(() => maps.id, { @@ -4794,6 +4807,10 @@ 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() @@ -4814,12 +4831,18 @@ 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() @@ -4835,21 +4858,44 @@ 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 }) @@ -4861,12 +4907,38 @@ 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", { @@ -8072,23 +8144,48 @@ export const relations_map_roads = relations(map_roads, ({ one }) => ({ relationName: "map", }), })); -export const relations_map_zones = relations(map_zones, ({ one }) => ({ +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 }) => ({ map: one(maps, { fields: [map_zones.map], references: [maps.id], relationName: "map", }), + _rels: many(map_zones_rels, { + relationName: "_rels", + }), })); -export const relations_resource_nodes = relations(resource_nodes, ({ one }) => ({ +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 }) => ({ map: one(maps, { fields: [resource_nodes.map], references: [maps.id], relationName: "map", }), - resource: one(resources, { - fields: [resource_nodes.resource], - references: [resources.id], - relationName: "resource", + _rels: many(resource_nodes_rels, { + relationName: "_rels", }), })); export const relations_narrative_events = relations(narrative_events, () => ({})); @@ -8757,6 +8854,7 @@ 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; @@ -8887,7 +8985,9 @@ 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; @@ -9032,7 +9132,9 @@ 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; diff --git a/src/payload-types.ts b/src/payload-types.ts index 2a4aa28..5716590 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -3791,7 +3791,7 @@ export interface MapRoad { strokeWeight?: number | null; strokeStyle?: ('solid' | 'dashed' | 'dotted') | null; /** - * Renders the road's name along the line at zoom-dependent intervals. + * Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this. */ 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. + * Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational). */ - type: 'water' | 'land'; + type: 'water' | 'land' | 'territory' | 'border'; /** * 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). + * Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border). */ fillOpacity?: number | null; /** diff --git a/tests/int/map-system.int.spec.ts b/tests/int/map-system.int.spec.ts index c3ad443..37e5752 100644 --- a/tests/int/map-system.int.spec.ts +++ b/tests/int/map-system.int.spec.ts @@ -14,12 +14,20 @@ 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, @@ -505,6 +513,28 @@ 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], @@ -523,6 +553,82 @@ 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[] = [ @@ -1083,6 +1189,98 @@ 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", () => {